diff --git a/.gitignore b/.gitignore
index 3b3253a6..c9dcfefc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,6 @@ Link Console Handoff.html
# vhs demo render workspace
docs/media/.aha-demo/
+
+# LinkBar build artifacts (never commit)
+apps/LinkBar/.build/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aea7a176..552f79a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,33 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI
## [Unreleased]
+## [2.0.0] - 2026-07-27
+
+Link gets a face. The memory layer is unchanged in shape — plain Markdown,
+review-gated writes, no LLM in the write path — but it is no longer only a
+CLI and an MCP server: **LinkBar** puts the review gate in your macOS menu
+bar, and memory becomes something that meets you rather than somewhere you
+go. That shift is why this is a major version.
+
+**No breaking changes.** Every CLI command, MCP tool, hook, and memory file
+from 1.7 works exactly as before; upgrading is `brew upgrade link`.
+
+### Added
+
+- **LinkBar 1.0** — Link's memory, ambient in your macOS menu bar (`apps/LinkBar`, Swift/SwiftUI, the `lnk --json` CLI is its entire backend). The review gate stops being a place you go and starts being something that meets you:
+ - **Memory Palette**: a global hotkey (⌥⌘M) opens a floating panel over any app — type to recall (copy straight into what you're writing), prefix with `+` to remember, review-gated as always.
+ - **Live agent pulse**: LinkBar detects agent sessions writing transcripts right now and shows a breathing "N agents active · project" row; the menu-bar icon carries a quiet green dot while memory is being made.
+ - **Capture notifications**: a new session capture posts a native banner — "Will save: " — with an Accept action right on it. Confirmed working on an unsigned/ad-hoc build (no Apple Developer signature required).
+ - **Memory browser**: a Memory tab listing every memory file — search, type filters, archived toggle, supersede lineage, archive/restore — read straight from `wiki/memories/*.md`.
+ - **Status dashboard**: health dots for CLI, workspace, MCP, hooks, recall tier, and viewer, each with a one-click fix that verifies its outcome before claiming success.
+ - **Inbox**: review/accept memories and captures with "Will save" previews, decision trails ("How Link read this session"), and per-proposal Accept menus.
+ - Distributed as an unsigned cask with a postflight quarantine-strip — `brew install --cask gowtham0992/link/linkbar` — zero Gatekeeper friction, zero signing fees.
+- Homepage refresh: version banner, per-agent onboard picker (real `lnk onboard --agent --write` commands), origin-story section, and the three demo terminals as alternating side-by-side rows.
+
+### Fixed
+
+- `lnk semantic` status reported "Indexed memories: 16 of 6" — the index embeds every memory (archived included; they stay inert in default recall), so the denominator is now the total memory count, not active-only.
+
## [1.7.0] - 2026-07-17
### Added
diff --git a/README.md b/README.md
index 1db993ac..4cc7bd1d 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
+
@@ -210,6 +211,27 @@ The generated demo is the public proof wiki. Generated content inside `wiki/`,
`raw/`, and `link-demo/` is ignored by git so personal memory is not published
by accident.
+## LinkBar — the menu bar app (macOS)
+
+Link's memory, ambient. LinkBar puts the review gate in your menu bar: a
+global palette (⌥⌘M) to recall or remember from any app, native
+notifications with one-tap Accept when a session capture lands, a live
+pulse while agents are writing, and a browser over every memory file —
+all running on the same reviewed `lnk` commands as the CLI.
+
+
+
+
+
+
+```bash
+brew install --cask gowtham0992/link/linkbar
+```
+
+Unsigned on purpose (no Apple fee inflating anything): the cask strips
+the quarantine flag on install, so it opens like any app. Building from
+source instead: `cd apps/LinkBar && bash Scripts/bundle.sh --install`.
+
## Killer Demo: One Memory, Two Agents
This is the moment Link is built for:
diff --git a/apps/LinkBar/.gitignore b/apps/LinkBar/.gitignore
new file mode 100644
index 00000000..30bcfa4e
--- /dev/null
+++ b/apps/LinkBar/.gitignore
@@ -0,0 +1 @@
+.build/
diff --git a/apps/LinkBar/Package.swift b/apps/LinkBar/Package.swift
new file mode 100644
index 00000000..47f42ac6
--- /dev/null
+++ b/apps/LinkBar/Package.swift
@@ -0,0 +1,14 @@
+// swift-tools-version: 5.10
+import PackageDescription
+
+let package = Package(
+ name: "LinkBar",
+ platforms: [.macOS(.v14)],
+ targets: [
+ .executableTarget(
+ name: "LinkBar",
+ path: "Sources/LinkBar",
+ resources: [.process("Resources")]
+ )
+ ]
+)
diff --git a/apps/LinkBar/README.md b/apps/LinkBar/README.md
new file mode 100644
index 00000000..66449fb4
--- /dev/null
+++ b/apps/LinkBar/README.md
@@ -0,0 +1,25 @@
+# LinkBar
+
+The review gate in your menu bar. Link's promise is that nothing becomes
+durable memory without your approval; LinkBar makes approving ambient
+instead of a chore.
+
+- Badge: pending-review count · Popover: the review inbox with one-click
+ approve (mark reviewed) and archive
+- Quick recall ("What do I know about…") with honest abstention — when the
+ memory has nothing reliable, it says so
+- Backend: the `lnk` CLI's `--json` output. No server, no sockets, no new
+ API surface. Workspace: `LINK_WORKSPACE` or `~/link`.
+
+## Build & run
+
+```
+cd apps/LinkBar
+swift build # debug binary
+./Scripts/bundle.sh # release .app bundle at .build/LinkBar.app
+open .build/LinkBar.app
+```
+
+Requires macOS 14+, Swift 5.10+, and Link installed (`brew install
+gowtham0992/link/link`). Status: early preview on the feature/menubar
+branch — not yet part of a release.
diff --git a/apps/LinkBar/Scripts/bundle.sh b/apps/LinkBar/Scripts/bundle.sh
new file mode 100755
index 00000000..a7c6e729
--- /dev/null
+++ b/apps/LinkBar/Scripts/bundle.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+# Wrap the SPM executable in a minimal .app bundle (menu-bar agent app).
+set -e
+cd "$(dirname "$0")/.."
+swift build -c release
+APP=.build/LinkBar.app
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+cp .build/release/LinkBar "$APP/Contents/MacOS/LinkBar"
+cp Sources/LinkBar/Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
+cp -R .build/release/LinkBar_LinkBar.bundle "$APP/Contents/Resources/" 2>/dev/null || true
+cat > "$APP/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleIdentifierio.github.gowtham0992.linkbar
+ CFBundleNameLinkBar
+ CFBundleDisplayNameLinkBar
+ CFBundleExecutableLinkBar
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString1.0.0
+ LSMinimumSystemVersion14.0
+ LSUIElement
+ CFBundleIconFileAppIcon
+
+
+PLIST
+codesign --force --sign - "$APP" 2>/dev/null || true
+echo "bundled: $APP"
+
+# --release-zip: produce the distributable zip the cask points at.
+if [ "$1" = "--release-zip" ]; then
+ VERSION=$(grep -o 'static let version = "[^"]*"' Sources/LinkBar/DesignSystem.swift | grep -o '"[^"]*"' | tr -d '"')
+ ZIP=".build/LinkBar-${VERSION}.0.zip"
+ rm -f "$ZIP"
+ ditto -c -k --keepParent "$APP" "$ZIP"
+ shasum -a 256 "$ZIP"
+ echo "release zip: $ZIP (attach to the GitHub release; put the sha256 in the cask)"
+ exit 0
+fi
+
+# --install: replace /Applications/LinkBar.app with this build and relaunch.
+if [ "$1" = "--install" ]; then
+ pkill -x LinkBar 2>/dev/null || true
+ sleep 1
+ rm -rf /Applications/LinkBar.app
+ cp -R "$APP" /Applications/LinkBar.app
+ open /Applications/LinkBar.app
+ echo "installed: /Applications/LinkBar.app (running)"
+fi
diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift
new file mode 100644
index 00000000..e0ed7ff7
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+
+/// LinkBar's design tokens: native macOS materials carry the structure,
+/// Link's rust carries the opinion, one serif wordmark is the signature.
+enum LinkBrand {
+ /// One source of truth for the app version shown in footer + settings.
+ static let version = "1.0"
+
+ /// Rust — Link's accent. Lifted and desaturated slightly in dark mode
+ /// so it reads as warm, not muddy, on dark materials.
+ static let rust = Color(nsColor: NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(red: 0.85, green: 0.47, blue: 0.34, alpha: 1) // #d97856
+ : NSColor(red: 0.66, green: 0.29, blue: 0.18, alpha: 1) // #a8492f
+ })
+
+ /// Spacing scale (4/8 rhythm): tight within groups, generous between.
+ static let inGroup: CGFloat = 6
+ static let betweenSections: CGFloat = 16
+ static let pad: CGFloat = 14
+}
+
+/// Uppercase tracked section label with an optional trailing count.
+struct SectionHeader: View {
+ let title: String
+ var count: Int?
+ var highlight = false
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Text(title.uppercased())
+ .font(.system(size: 10.5, weight: .semibold))
+ .tracking(0.7)
+ .foregroundStyle(.secondary)
+ Spacer()
+ if let count, count > 0 {
+ Text("\(count)")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(highlight ? Color.white : Color.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1.5)
+ .background(highlight ? AnyShapeStyle(LinkBrand.rust) : AnyShapeStyle(.quaternary))
+ .clipShape(Capsule())
+ }
+ }
+ }
+}
+
+/// Row container with a soft hover highlight — the popover feels alive.
+struct HoverRow: View {
+ @State private var hovering = false
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ content
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(hovering ? AnyShapeStyle(.quaternary.opacity(0.6)) : AnyShapeStyle(.clear))
+ )
+ .onHover { hovering = $0 }
+ .animation(.easeOut(duration: 0.15), value: hovering)
+ }
+}
+
+/// The Link wordmark: the one serif moment in the app.
+struct Wordmark: View {
+ var body: some View {
+ HStack(spacing: 7) {
+ Circle()
+ .fill(LinkBrand.rust)
+ .frame(width: 7, height: 7)
+ Text("Link")
+ .font(.system(size: 15, weight: .semibold, design: .serif))
+ }
+ }
+}
+
+
+/// Fourteen days of memory activity as tiny bars — the "pulse" of the
+/// workspace. Rust bars on quiet quaternary track; today is the last bar.
+struct Sparkline: View {
+ let values: [Int]
+
+ private var peak: Int { max(values.max() ?? 1, 1) }
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 3) {
+ ForEach(Array(values.enumerated()), id: \.offset) { _, value in
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(value > 0 ? AnyShapeStyle(LinkBrand.rust.opacity(0.55 + 0.45 * Double(value) / Double(peak)))
+ : AnyShapeStyle(.quaternary.opacity(0.6)))
+ .frame(width: 9, height: value > 0 ? max(6, 26 * CGFloat(value) / CGFloat(peak)) : 3)
+ }
+ }
+ .frame(height: 26, alignment: .bottom)
+ .accessibilityLabel("Memory activity, last \(values.count) days")
+ }
+}
+
+/// Small stat: big number, quiet label underneath.
+struct StatChip: View {
+ let value: String
+ let label: String
+ var tint: Color = .primary
+
+ var body: some View {
+ VStack(spacing: 1) {
+ Text(value)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundStyle(tint)
+ Text(label.uppercased())
+ .font(.system(size: 8.5, weight: .medium))
+ .tracking(0.5)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+/// A colored health dot for a Link surface (green / amber / red / neutral).
+struct HealthDot: View {
+ let level: SurfaceHealth.Level
+ var body: some View {
+ let c = level.color
+ Circle()
+ .fill(Color(red: c.r, green: c.g, blue: c.b))
+ .frame(width: 8, height: 8)
+ .overlay(
+ Circle().stroke(Color(red: c.r, green: c.g, blue: c.b).opacity(0.35), lineWidth: 3)
+ .opacity(level == .error || level == .warn ? 1 : 0)
+ )
+ }
+}
+
+/// One row in the Status dashboard: dot · icon · name/detail · optional Fix.
+struct StatusRow: View {
+ let surface: SurfaceHealth
+ @State private var hovering = false
+
+ var body: some View {
+ HStack(spacing: 10) {
+ HealthDot(level: surface.level)
+ Image(systemName: surface.icon)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .frame(width: 16)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(surface.name)
+ .font(.system(size: 12.5, weight: .medium))
+ Text(surface.detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ Spacer(minLength: 6)
+ if let fix = surface.fix {
+ Button(fix.label) { fix.action() }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .tint(LinkBrand.rust)
+ }
+ }
+ .padding(.horizontal, 8).padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(hovering ? AnyShapeStyle(.quaternary.opacity(0.5)) : AnyShapeStyle(.clear))
+ )
+ .onHover { hovering = $0 }
+ .animation(.easeOut(duration: 0.15), value: hovering)
+ }
+}
+
+/// A softly breathing green dot — "agents are working right now".
+struct PulseDot: View {
+ @State private var on = false
+ var body: some View {
+ Circle()
+ .fill(Color.green)
+ .frame(width: 7, height: 7)
+ .overlay(
+ Circle()
+ .stroke(Color.green.opacity(0.5), lineWidth: 1.5)
+ .scaleEffect(on ? 2.1 : 1.0)
+ .opacity(on ? 0 : 0.8)
+ )
+ .onAppear {
+ withAnimation(.easeOut(duration: 1.6).repeatForever(autoreverses: false)) { on = true }
+ }
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift
new file mode 100644
index 00000000..f9d26712
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+
+/// LinkBar — the review gate in your menu bar.
+///
+/// Link's promise is that nothing becomes durable memory without your
+/// approval; LinkBar makes approving ambient instead of a chore. The icon
+/// is Link's memory-graph mark, the badge is everything pending your
+/// judgment (memories to review + captures to accept), the popover is the
+/// inbox. The `lnk` CLI's --json output is the entire backend.
+@main
+struct LinkBarApp: App {
+ @StateObject private var store = LinkStore()
+
+ var body: some Scene {
+ MenuBarExtra {
+ PopoverView()
+ .environmentObject(store)
+ .onAppear {
+ store.start()
+ PaletteController.shared.install(store: store)
+ NotificationManager.shared.install(store: store)
+ }
+ } label: {
+ HStack(spacing: 3) {
+ if let icon = Self.menuIcon {
+ Image(nsImage: icon)
+ } else {
+ Image(systemName: "circle.hexagongrid")
+ }
+ if store.pendingCount > 0 {
+ Text("\(store.pendingCount)")
+ .font(.system(size: 11, weight: .semibold))
+ } else if !store.activeSessions.isEmpty {
+ // Agents are writing transcripts right now and nothing
+ // needs you: a quiet green dot says memory is being made.
+ Circle()
+ .fill(Color(red: 0.30, green: 0.72, blue: 0.42))
+ .frame(width: 5, height: 5)
+ } else if store.anyUnhealthy {
+ // No pending reviews, but a Link surface needs attention
+ // (stale runtime, MCP drift, hooks not wired): a subtle
+ // amber dot invites a look at the Status tab.
+ Circle()
+ .fill(Color(red: 0.90, green: 0.62, blue: 0.20))
+ .frame(width: 6, height: 6)
+ }
+ }
+ .onAppear { Self.snapshotIfRequested(store: store); Self.notifyTestIfRequested() }
+ }
+ .menuBarExtraStyle(.window)
+ }
+
+ /// Development aid: LINKBAR_SNAPSHOT=/path.png renders the popover to a
+ /// PNG (after one refresh) and exits — screenshot verification without
+ /// menubar clicks or screen-recording permission.
+ /// LINKBAR_NOTIFY_TEST=1 asks macOS for notification authorization,
+ /// prints the verdict, and exits — the ad-hoc-signing viability check.
+ @MainActor
+ static func notifyTestIfRequested() {
+ let marker = "/tmp/linkbar-notify-probe"
+ let byEnv = ProcessInfo.processInfo.environment["LINKBAR_NOTIFY_TEST"] != nil
+ let byFile = FileManager.default.fileExists(atPath: marker)
+ guard byEnv || byFile else { return }
+ NotificationManager.shared.probeAuthorization { result in
+ let line = "LINKBAR_NOTIFY: " + result + "\n"
+ try? line.write(toFile: "/tmp/linkbar-notify-result.txt", atomically: true, encoding: .utf8)
+ FileHandle.standardError.write(line.data(using: .utf8)!)
+ try? FileManager.default.removeItem(atPath: marker)
+ NSApplication.shared.terminate(nil)
+ }
+ }
+
+ @MainActor
+ static func snapshotIfRequested(store: LinkStore) {
+ guard let path = ProcessInfo.processInfo.environment["LINKBAR_SNAPSHOT"] else { return }
+ if let appearance = ProcessInfo.processInfo.environment["LINKBAR_APPEARANCE"] {
+ NSApplication.shared.appearance = NSAppearance(
+ named: appearance == "light" ? .aqua : .darkAqua
+ )
+ }
+ store.start()
+ let palette = ProcessInfo.processInfo.environment["LINKBAR_PALETTE"] != nil
+ Task { @MainActor in
+ // Real AppKit backing so SF Symbols and text fields render.
+ let host = NSHostingView(
+ rootView: AnyView(palette
+ ? AnyView(PaletteView(dismiss: {}).environmentObject(store)
+ .frame(width: 560).padding(24).background(Color.black.opacity(0.35)))
+ : AnyView(PopoverView().environmentObject(store)
+ .background(Color(nsColor: .windowBackgroundColor))))
+ )
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 10),
+ styleMask: [.borderless], backing: .buffered, defer: false
+ )
+ window.contentView = host
+ window.setContentSize(host.fittingSize)
+ try? await Task.sleep(nanoseconds: 4_000_000_000)
+ window.setContentSize(host.fittingSize)
+ host.layoutSubtreeIfNeeded()
+ if let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) {
+ host.cacheDisplay(in: host.bounds, to: rep)
+ if let png = rep.representation(using: .png, properties: [:]) {
+ try? png.write(to: URL(fileURLWithPath: path))
+ }
+ }
+ NSApplication.shared.terminate(nil)
+ }
+ }
+
+ /// The Link mark as a template image so it adapts to menu bar appearance.
+ private static let menuIcon: NSImage? = {
+ guard let url = Bundle.module.url(forResource: "MenuIcon", withExtension: "png"),
+ let image = NSImage(contentsOf: url)
+ else { return nil }
+ image.isTemplate = true
+ image.size = NSSize(width: 18, height: 18)
+ return image
+ }()
+}
diff --git a/apps/LinkBar/Sources/LinkBar/LinkCLI.swift b/apps/LinkBar/Sources/LinkBar/LinkCLI.swift
new file mode 100644
index 00000000..6c3d3fb2
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/LinkCLI.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+/// Bridge to the `lnk` CLI. The CLI's `--json` output is LinkBar's entire
+/// backend: no server, no sockets, no new API surface — the same reviewed
+/// commands every other Link surface uses.
+enum LinkCLI {
+ struct CLIError: Error, CustomStringConvertible {
+ let message: String
+ var description: String { message }
+ }
+
+ /// Workspace the app operates on: LINK_WORKSPACE or ~/link,
+ /// mirroring the CLI's own pathless-command fallback.
+ static var workspace: String {
+ if let env = ProcessInfo.processInfo.environment["LINK_WORKSPACE"], !env.isEmpty {
+ return (env as NSString).expandingTildeInPath
+ }
+ return (NSHomeDirectory() as NSString).appendingPathComponent("link")
+ }
+
+ /// Locate the lnk launcher. Order: LINK_CLI env, Homebrew paths, PATH.
+ static func lnkPath() -> String? {
+ if let env = ProcessInfo.processInfo.environment["LINK_CLI"], !env.isEmpty {
+ return env
+ }
+ let candidates = ["/opt/homebrew/bin/lnk", "/usr/local/bin/lnk"]
+ for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+ let which = Process()
+ which.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ which.arguments = ["lnk"]
+ let pipe = Pipe()
+ which.standardOutput = pipe
+ which.standardError = Pipe()
+ try? which.run()
+ which.waitUntilExit()
+ let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return out.isEmpty ? nil : out
+ }
+
+ /// Run `lnk ` and return stdout. Blocking — call off the main actor.
+ static func run(_ args: [String]) throws -> Data {
+ guard let lnk = lnkPath() else {
+ throw CLIError(message: "lnk not found — install Link (brew install gowtham0992/link/link) or set LINK_CLI")
+ }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: lnk)
+ process.arguments = args
+ let stdout = Pipe()
+ let stderr = Pipe()
+ process.standardOutput = stdout
+ process.standardError = stderr
+ try process.run()
+ let data = stdout.fileHandleForReading.readDataToEndOfFile()
+ let errData = stderr.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ if process.terminationStatus != 0 && data.isEmpty {
+ let message = String(data: errData, encoding: .utf8) ?? "lnk exited \(process.terminationStatus)"
+ throw CLIError(message: message.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+ return data
+ }
+
+ /// Run an arbitrary executable + args (not the `lnk` launcher) — used for
+ /// the exact remediation commands `verify-mcp` emits (e.g. a venv pip
+ /// upgrade). Blocking; call off the main actor.
+ @discardableResult
+ static func runRaw(_ command: [String]) throws -> Data {
+ guard let executable = command.first else {
+ throw CLIError(message: "empty command")
+ }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = Array(command.dropFirst())
+ let stdout = Pipe()
+ process.standardOutput = stdout
+ process.standardError = Pipe()
+ try process.run()
+ let data = stdout.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ return data
+ }
+
+ /// Fire-and-forget for long-lived processes (the local viewer).
+ static func launchDetached(_ args: [String]) {
+ guard let lnk = lnkPath() else { return }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: lnk)
+ process.arguments = args
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try? process.run()
+ }
+
+ static func runJSON(_ type: T.Type, _ args: [String]) throws -> T {
+ let data = try run(args)
+ return try JSONDecoder().decode(type, from: data)
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift
new file mode 100644
index 00000000..0f78ca83
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift
@@ -0,0 +1,671 @@
+import AppKit
+import Foundation
+import ServiceManagement
+import SwiftUI
+
+/// App state: review inbox, capture inbox, recent activity, quick recall —
+/// all refreshed from the CLI's --json output. The workspace directories are
+/// watched directly, so the badge updates the moment a session hook writes a
+/// capture — no polling delay.
+@MainActor
+final class LinkStore: ObservableObject {
+ enum FlashTone { case success, info }
+
+ @Published var inbox: MemoryInbox?
+ @Published var captures: CaptureInbox?
+ @Published var activity: [LogEntry] = []
+ @Published var recallResults: [RecalledMemory] = []
+ @Published var searchedQuery: String?
+ @Published var abstention: Abstention?
+ @Published var lastError: String?
+ @Published var flash: String?
+ @Published var flashTone: FlashTone = .success
+ @Published var busy = false
+ @Published var linkVersion: String = ""
+ @Published var stats: StatusPayload?
+ @Published var runtimeWarning: String?
+ @Published var launchAtLogin: Bool = SMAppService.mainApp.status == .enabled
+
+ // Status dashboard: the health of every Link surface.
+ @Published var mcp: MCPVerify?
+ @Published var semantic: SemanticStatus?
+ @Published var claudeHooksWired: Bool?
+ @Published var viewerRunning = false
+ @Published var activeSessions: [AgentSession] = []
+ @Published var memories: [MemoryPage] = []
+ private var lastHealthAt = Date.distantPast
+
+ var pendingCount: Int {
+ (inbox?.reviewCount ?? 0) + (captures?.count ?? 0)
+ }
+
+ /// Memory writes per day for the last `days` days (today last) —
+ /// derived from the log, no extra CLI call.
+ func activityPulse(days: Int = 14) -> [Int] {
+ var buckets = [Int](repeating: 0, count: days)
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: Date())
+ for entry in activity {
+ guard let date = entry.date else { continue }
+ let delta = calendar.dateComponents([.day], from: calendar.startOfDay(for: date), to: today).day ?? .max
+ if delta >= 0 && delta < days {
+ buckets[days - 1 - delta] += 1
+ }
+ }
+ return buckets
+ }
+
+ private var timer: Timer?
+ private var watchers: [DirectoryWatcher] = []
+ private var refreshDebounce: DispatchWorkItem?
+ private var flashGeneration = 0
+ private var started = false
+
+ func start() {
+ // The popover calls this on every open; guard so timers and
+ // watchers are created exactly once per app lifetime.
+ if started { refresh(); return }
+ started = true
+ refresh()
+ // Fallback heartbeat only — the directory watchers do the real work.
+ timer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ }
+ startWatching()
+ }
+
+ /// Watch the paths that change when memory changes: captures land in
+ /// raw/memory-captures, memories in wiki/memories, log in wiki.
+ private var watchPaths: [String] {
+ let root = LinkCLI.workspace
+ return [
+ root,
+ (root as NSString).appendingPathComponent("raw/memory-captures"),
+ (root as NSString).appendingPathComponent("wiki/memories"),
+ (root as NSString).appendingPathComponent("wiki"),
+ ]
+ }
+
+ private func startWatching() {
+ watchers = watchPaths.compactMap { path in
+ DirectoryWatcher(path: path) { [weak self] in
+ Task { @MainActor in self?.scheduleRefresh() }
+ }
+ }
+ }
+
+ /// A fresh workspace may not have raw/memory-captures yet, so its
+ /// watcher fails at launch; once the first capture creates the
+ /// directory, pick it up instead of staying blind until restart.
+ private func healWatchersIfNeeded() {
+ guard watchers.count < watchPaths.count else { return }
+ startWatching()
+ }
+
+ /// Coalesce watcher bursts (a single accept touches several files).
+ private func scheduleRefresh() {
+ refreshDebounce?.cancel()
+ let work = DispatchWorkItem { [weak self] in
+ Task { @MainActor in self?.refresh() }
+ }
+ refreshDebounce = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)
+ }
+
+ func refresh() {
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ let workspace = LinkCLI.workspace
+ let inbox = try? LinkCLI.runJSON(MemoryInbox.self, ["memory-inbox", workspace, "--json"])
+ let captures = try? LinkCLI.runJSON(CaptureInbox.self, ["capture-inbox", workspace, "--json"])
+ let log = try? LinkCLI.runJSON(MemoryLog.self, ["memory-log", workspace, "--json", "--limit", "200"])
+ let status = try? LinkCLI.runJSON(StatusPayload.self, ["status", workspace, "--json"])
+ let sessions = Self.scanAgentSessions()
+ let memories = MemoryPage.load(from: workspace)
+ await MainActor.run {
+ self.activeSessions = sessions
+ self.memories = memories
+ if inbox == nil && captures == nil {
+ self.lastError = "Could not reach lnk — is Link installed? (brew install gowtham0992/link/link)"
+ } else {
+ self.lastError = nil
+ }
+ self.inbox = inbox ?? self.inbox
+ self.captures = captures ?? self.captures
+ self.activity = log.map { Array($0.entries.reversed()) } ?? self.activity
+ if let status {
+ self.stats = status
+ self.linkVersion = status.version ?? self.linkVersion
+ self.runtimeWarning = status.warnings?
+ .first { $0.code == "stale_runtime" }?
+ .message
+ }
+ self.busy = false
+ self.healWatchersIfNeeded()
+ if let caps = self.captures?.captures {
+ NotificationManager.shared.announceNewCaptures(caps)
+ }
+ }
+ // Health surfaces are heavier (each spawns a Python probe), so
+ // refresh them at most every 15s and after the fast data is on
+ // screen — the dots fill in a moment later without blocking.
+ await self.refreshHealthIfDue()
+ }
+ }
+
+ /// Force a health refresh now (used by the manual refresh button and
+ /// when the Status tab opens).
+ func refreshHealth() {
+ Task.detached(priority: .utility) { await self.fetchHealth() }
+ }
+
+ private func refreshHealthIfDue() async {
+ let due = await MainActor.run { Date().timeIntervalSince(self.lastHealthAt) > 15 }
+ if due { await fetchHealth() }
+ }
+
+ private func fetchHealth() async {
+ let workspace = LinkCLI.workspace
+ let mcp = try? LinkCLI.runJSON(MCPVerify.self, ["verify-mcp", workspace, "--json"])
+ let semantic = try? LinkCLI.runJSON(SemanticStatus.self, ["semantic", workspace, "--json"])
+ let hooks = Self.claudeHooksAreWired()
+ let viewer = await Self.viewerResponds()
+ await MainActor.run {
+ self.mcp = mcp ?? self.mcp
+ self.semantic = semantic ?? self.semantic
+ self.claudeHooksWired = hooks
+ self.viewerRunning = viewer
+ self.lastHealthAt = Date()
+ }
+ }
+
+ /// Detect live agent sessions: a Claude Code project whose newest
+ /// transcript was written in the last 5 minutes is "active now".
+ /// (Transcripts stream continuously while a session runs.) Codex/Cursor
+ /// roots can join this scan later.
+ nonisolated private static func scanAgentSessions(activeWindow: TimeInterval = 300) -> [AgentSession] {
+ let fm = FileManager.default
+ let root = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/projects")
+ guard let projects = try? fm.contentsOfDirectory(atPath: root) else { return [] }
+ var found: [AgentSession] = []
+ let now = Date()
+ for slug in projects where !slug.hasPrefix(".") {
+ let dir = (root as NSString).appendingPathComponent(slug)
+ guard let files = try? fm.contentsOfDirectory(atPath: dir) else { continue }
+ var newest = Date.distantPast
+ for f in files where f.hasSuffix(".jsonl") {
+ let path = (dir as NSString).appendingPathComponent(f)
+ if let m = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date, m > newest {
+ newest = m
+ }
+ }
+ if now.timeIntervalSince(newest) < activeWindow {
+ // Slug is the full path with dashes; the tail is the repo name.
+ let project = slug.split(separator: "-").last.map(String.init) ?? slug
+ found.append(AgentSession(project: project, lastActive: newest))
+ }
+ }
+ return found.sorted { $0.lastActive > $1.lastActive }
+ }
+
+ /// Read Claude Code's settings.json directly to see whether Link's
+ /// session hooks are wired (the flagship agent; other agents live in
+ /// their own configs and are added as the dashboard grows).
+ nonisolated private static func claudeHooksAreWired() -> Bool {
+ let path = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/settings.json")
+ guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return false }
+ return text.contains("SessionStart") && text.contains("hook session-start")
+ }
+
+ /// The live dashboard rows, most-critical surfaces first.
+ func surfaces() -> [SurfaceHealth] {
+ var rows: [SurfaceHealth] = []
+
+ // CLI
+ if linkVersion.isEmpty && lastError != nil {
+ rows.append(.init(icon: "terminal", name: "CLI", level: .error,
+ detail: "lnk not found on PATH",
+ fix: .init(label: "Install") { [weak self] in self?.openInstallDocs() }))
+ } else {
+ rows.append(.init(icon: "terminal", name: "CLI", level: .ok,
+ detail: linkVersion.isEmpty ? "installed" : "lnk \(linkVersion)"))
+ }
+
+ // Workspace
+ if let runtimeWarning {
+ rows.append(.init(icon: "shippingbox", name: "Workspace", level: .warn,
+ detail: "runtime is stale — recall may use old logic",
+ fix: .init(label: "Refresh") { [weak self] in self?.repairRuntime() }))
+ } else if let s = stats {
+ let review = s.needsReviewCount ?? 0
+ let level: SurfaceHealth.Level = review > 0 ? .info : .ok
+ let counts = "\(s.activeMemoryCount ?? 0) active · \(s.contentPageCount ?? 0) pages"
+ rows.append(.init(icon: "shippingbox", name: "Workspace", level: level,
+ detail: review > 0 ? "\(counts) · \(review) to review" : counts))
+ } else {
+ rows.append(.init(icon: "shippingbox", name: "Workspace", level: .info, detail: "checking…"))
+ }
+
+ // MCP
+ if let m = mcp {
+ if m.ready {
+ rows.append(.init(icon: "point.3.connected.trianglepath.dotted", name: "MCP", level: .ok,
+ detail: "ready · link-mcp \(m.linkMcp?.version ?? "?")"))
+ } else if m.linkMcp?.installed != true {
+ rows.append(.init(icon: "point.3.connected.trianglepath.dotted", name: "MCP", level: .error,
+ detail: "server not provisioned",
+ fix: .init(label: "Repair") { [weak self] in self?.repairRuntime() }))
+ } else {
+ let want = m.expectedVersion ?? "?"
+ rows.append(.init(icon: "point.3.connected.trianglepath.dotted", name: "MCP", level: .warn,
+ detail: "version \(m.linkMcp?.version ?? "?") ≠ Link \(want)",
+ fix: .init(label: "Fix") { [weak self] in self?.upgradeMCP() }))
+ }
+ } else {
+ rows.append(.init(icon: "point.3.connected.trianglepath.dotted", name: "MCP", level: .info, detail: "checking…"))
+ }
+
+ // Hooks (Claude Code)
+ switch claudeHooksWired {
+ case .some(true):
+ rows.append(.init(icon: "bolt.horizontal", name: "Hooks", level: .ok,
+ detail: "Claude Code: session capture wired"))
+ case .some(false):
+ rows.append(.init(icon: "bolt.horizontal", name: "Hooks", level: .warn,
+ detail: "Claude Code: not wired — no automatic capture",
+ fix: .init(label: "Wire") { [weak self] in self?.wireClaudeHooks() }))
+ case .none:
+ rows.append(.init(icon: "bolt.horizontal", name: "Hooks", level: .info, detail: "checking…"))
+ }
+
+ // Recall power (semantic tier)
+ if let sem = semantic {
+ if sem.enabled, let tier = sem.tier {
+ let rerank = (sem.rerankReady == true) ? " + rerank" : ""
+ rows.append(.init(icon: "sparkle.magnifyingglass", name: "Recall", level: .ok,
+ detail: "\(tier.capitalized) semantic\(rerank)"))
+ } else {
+ rows.append(.init(icon: "sparkle.magnifyingglass", name: "Recall", level: .info,
+ detail: "Lexical only — no semantic matching yet",
+ fix: .init(label: "Enable") { [weak self] in self?.setupSemantic() }))
+ }
+ } else {
+ rows.append(.init(icon: "sparkle.magnifyingglass", name: "Recall", level: .info, detail: "checking…"))
+ }
+
+ // Viewer
+ rows.append(.init(icon: "gauge.with.needle", name: "Viewer",
+ level: viewerRunning ? .ok : .info,
+ detail: viewerRunning ? "running · 127.0.0.1:3000" : "not running",
+ fix: viewerRunning ? nil : .init(label: "Open") { [weak self] in self?.openDashboard() }))
+
+ return rows
+ }
+
+ /// Any surface that a user would want to act on (amber menu-bar dot).
+ var anyUnhealthy: Bool {
+ surfaces().contains { $0.level == .warn || $0.level == .error }
+ }
+
+ // MARK: Memory Palette (global-hotkey recall/remember)
+
+ /// Palette recall: returns results to a callback without disturbing the
+ /// popover's own recall state.
+ func paletteRecall(_ query: String, then: @escaping ([RecalledMemory], Abstention?) -> Void) {
+ let q = query.trimmingCharacters(in: .whitespaces)
+ guard !q.isEmpty else { then([], nil); return }
+ Task.detached(priority: .userInitiated) {
+ let payload = try? LinkCLI.runJSON(RecallPayload.self, ["recall", q, LinkCLI.workspace, "--json"])
+ await MainActor.run { then(payload?.memories ?? [], payload?.abstention) }
+ }
+ }
+
+ /// Palette remember: review-gated write, result to a callback so the
+ /// floating panel can confirm inline (its flash is offscreen).
+ func paletteRemember(_ text: String, then: @escaping (RememberResult?) -> Void) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { then(nil); return }
+ let bounded = String(trimmed.prefix(2000))
+ Task.detached(priority: .userInitiated) {
+ let result = try? LinkCLI.runJSON(RememberResult.self, ["remember", bounded, LinkCLI.workspace, "--json"])
+ await MainActor.run { self.refresh(); then(result) }
+ }
+ }
+
+ func recall(_ query: String) {
+ guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return }
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ do {
+ let payload = try LinkCLI.runJSON(
+ RecallPayload.self,
+ ["recall", query, LinkCLI.workspace, "--json"]
+ )
+ await MainActor.run {
+ self.recallResults = payload.memories
+ self.abstention = payload.abstention
+ self.searchedQuery = query
+ self.busy = false
+ }
+ } catch {
+ await MainActor.run {
+ self.lastError = String(describing: error)
+ self.busy = false
+ }
+ }
+ }
+ }
+
+ /// Approve: mark the memory reviewed. The gate, one click.
+ func markReviewed(_ item: InboxItem) {
+ act(["review-memory", item.name, LinkCLI.workspace])
+ }
+
+ /// Reject: archive the memory (never silent deletion).
+ func archive(_ item: InboxItem) {
+ act(["archive-memory", item.name, LinkCLI.workspace])
+ }
+
+ /// Accept a session capture proposal into the reviewed memory flow.
+ func acceptCapture(_ capture: CaptureItem, index: Int = 1) {
+ act(["accept-capture", capture.path, LinkCLI.workspace, "--index", "\(index)"])
+ }
+
+ /// Archive/restore straight from the memory browser.
+ func archiveMemory(named name: String) {
+ act(["archive-memory", name, LinkCLI.workspace])
+ }
+
+ func restoreMemory(named name: String) {
+ act(["restore-memory", name, LinkCLI.workspace])
+ }
+
+ /// Accept a capture from a notification banner (path only, first proposal).
+ func acceptCaptureByPath(_ path: String) {
+ act(["accept-capture", path, LinkCLI.workspace, "--index", "1"])
+ showFlash("Accepted from notification.", tone: .success)
+ }
+
+ func deleteCapture(_ capture: CaptureItem) {
+ act(["delete-capture", capture.path, LinkCLI.workspace, "--confirm"])
+ }
+
+ /// Save typed text as a memory — review-gated like every other write.
+ func rememberText(_ text: String) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ showFlash("Nothing to remember — type something first.", tone: .info)
+ return
+ }
+ let bounded = String(trimmed.prefix(2000))
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ do {
+ let result = try LinkCLI.runJSON(
+ RememberResult.self,
+ ["remember", bounded, LinkCLI.workspace, "--json"]
+ )
+ await MainActor.run {
+ if result.created {
+ self.showFlash("Saved — pending your review.", tone: .success)
+ } else if result.secret == true {
+ self.showFlash("Not saved — that looks like a secret. Use a password manager.", tone: .info)
+ self.busy = false
+ } else {
+ self.showFlash("Not saved — a similar or conflicting memory exists.", tone: .info)
+ self.busy = false
+ }
+ self.refresh()
+ }
+ } catch {
+ await MainActor.run {
+ self.lastError = String(describing: error)
+ self.busy = false
+ }
+ }
+ }
+ }
+
+ /// Save the clipboard as a memory — review-gated like every other write.
+ func rememberClipboard() {
+ guard let text = NSPasteboard.general.string(forType: .string)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !text.isEmpty
+ else {
+ showFlash("Clipboard has no text.", tone: .info)
+ return
+ }
+ rememberText(text)
+ }
+
+ /// Refresh the workspace runtime copy (the stale_runtime repair).
+ func repairRuntime() {
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ do {
+ _ = try LinkCLI.run(["init", LinkCLI.workspace])
+ await MainActor.run {
+ self.showFlash("Workspace runtime refreshed.", tone: .success)
+ self.runtimeWarning = nil
+ self.refresh()
+ }
+ } catch {
+ await MainActor.run {
+ self.lastError = String(describing: error)
+ self.busy = false
+ }
+ }
+ }
+ }
+
+ // MARK: Status-dashboard remediations
+
+ /// Install the semantic tier into the managed venv and fetch the model
+ /// (the only network step Link takes, with the user's click as consent).
+ ///
+ /// `--setup` only actually provisions on Link 1.7+; older CLIs just print
+ /// the manual pip steps. So we verify the *outcome* (re-read semantic
+ /// --json) rather than trusting the exit code, and flash the truth.
+ func setupSemantic() {
+ busy = true
+ showFlash("Setting up semantic recall…", tone: .info)
+ Task.detached(priority: .userInitiated) {
+ _ = try? LinkCLI.run(["semantic", LinkCLI.workspace, "--setup"])
+ let after = try? LinkCLI.runJSON(SemanticStatus.self, ["semantic", LinkCLI.workspace, "--json"])
+ await MainActor.run {
+ self.busy = false
+ self.semantic = after ?? self.semantic
+ if after?.enabled == true {
+ self.showFlash("Semantic recall ready — \(after?.tier ?? "on").", tone: .success)
+ } else {
+ self.showFlash("Needs a one-time install — run: lnk semantic \(LinkCLI.workspace) --setup", tone: .info)
+ }
+ self.refreshHealth()
+ }
+ }
+ }
+
+ /// Bring link-mcp in the workspace venv to Link's version by running the
+ /// exact upgrade command verify-mcp emits, then confirm it actually took.
+ func upgradeMCP() {
+ guard let command = mcp?.nextActions?.first?.command, !command.isEmpty else {
+ repairRuntime() // fallback: refresh the workspace runtime copy
+ return
+ }
+ busy = true
+ showFlash("Updating link-mcp…", tone: .info)
+ Task.detached(priority: .userInitiated) {
+ _ = try? LinkCLI.runRaw(command)
+ let after = try? LinkCLI.runJSON(MCPVerify.self, ["verify-mcp", LinkCLI.workspace, "--json"])
+ await MainActor.run {
+ self.busy = false
+ self.mcp = after ?? self.mcp
+ if after?.ready == true {
+ self.showFlash("MCP updated to Link \(after?.expectedVersion ?? "").", tone: .success)
+ } else {
+ self.showFlash("Couldn't auto-update — run: \(command.joined(separator: " "))", tone: .info)
+ }
+ self.refreshHealth()
+ }
+ }
+ }
+
+ /// Wire Claude Code's session hooks (capture on session end, brief on
+ /// session start) — the automatic loop, one click — then confirm they
+ /// actually landed in the settings file.
+ func wireClaudeHooks() {
+ busy = true
+ showFlash("Wiring Claude Code hooks…", tone: .info)
+ Task.detached(priority: .userInitiated) {
+ _ = try? LinkCLI.run(["connect", "claude-code", LinkCLI.workspace, "--hooks", "--write"])
+ let wired = Self.claudeHooksAreWired()
+ await MainActor.run {
+ self.busy = false
+ self.claudeHooksWired = wired
+ self.showFlash(wired
+ ? "Hooks wired — new sessions capture automatically."
+ : "Couldn't wire hooks — check Claude Code settings.",
+ tone: wired ? .success : .info)
+ self.refreshHealth()
+ }
+ }
+ }
+
+ func openInstallDocs() {
+ NSWorkspace.shared.open(URL(string: "https://github.com/gowtham0992/link#quick-start")!)
+ }
+
+ func revealMemory(named name: String) {
+ let path = (LinkCLI.workspace as NSString)
+ .appendingPathComponent("wiki/memories/\(name).md")
+ NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
+ }
+
+ func revealCapture(_ capture: CaptureItem) {
+ let path = (LinkCLI.workspace as NSString).appendingPathComponent(capture.path)
+ NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
+ }
+
+ func setLaunchAtLogin(_ enabled: Bool) {
+ do {
+ if enabled {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ launchAtLogin = SMAppService.mainApp.status == .enabled
+ } catch {
+ launchAtLogin = SMAppService.mainApp.status == .enabled
+ showFlash("Login item needs the bundled app (Scripts/bundle.sh).", tone: .info)
+ }
+ }
+
+ /// Put text on the clipboard — for pasting a memory into a prompt.
+ func copyText(_ text: String) {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ showFlash("Copied.", tone: .success)
+ }
+
+ func clearSearch() {
+ recallResults = []
+ searchedQuery = nil
+ abstention = nil
+ }
+
+ func openWorkspace() {
+ NSWorkspace.shared.open(URL(fileURLWithPath: LinkCLI.workspace))
+ }
+
+ /// Open the full Memory Dashboard in the browser, starting the local
+ /// viewer first if it is not already running (127.0.0.1 only — the
+ /// viewer refuses to bind anywhere else by design).
+ func openDashboard() {
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ let dashboard = URL(string: "http://127.0.0.1:3000/memory")!
+ if await Self.viewerResponds() {
+ await MainActor.run {
+ NSWorkspace.shared.open(dashboard)
+ self.busy = false
+ }
+ return
+ }
+ LinkCLI.launchDetached(["serve", LinkCLI.workspace, "--port", "3000"])
+ for _ in 0..<20 where !(await Self.viewerResponds()) {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ }
+ await MainActor.run {
+ NSWorkspace.shared.open(dashboard)
+ self.showFlash("Viewer started at 127.0.0.1:3000", tone: .success)
+ self.busy = false
+ }
+ }
+ }
+
+ /// Show a transient status line; fades on its own.
+ private func showFlash(_ message: String, tone: FlashTone) {
+ flashGeneration += 1
+ let generation = flashGeneration
+ flash = message
+ flashTone = tone
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 4_000_000_000)
+ if self.flashGeneration == generation {
+ withAnimation(.easeOut(duration: 0.4)) { self.flash = nil }
+ }
+ }
+ }
+
+ private static func viewerResponds() async -> Bool {
+ var request = URLRequest(url: URL(string: "http://127.0.0.1:3000/memory")!)
+ request.timeoutInterval = 0.5
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return false }
+ // Some other dev server may own :3000 — only treat it as ours
+ // when the page is recognizably the Link viewer.
+ let body = String(data: data.prefix(4096), encoding: .utf8) ?? ""
+ return body.contains("Link")
+ } catch {
+ return false
+ }
+ }
+
+ private func act(_ args: [String]) {
+ busy = true
+ Task.detached(priority: .userInitiated) {
+ do {
+ _ = try LinkCLI.run(args)
+ await MainActor.run { self.refresh() }
+ } catch {
+ await MainActor.run {
+ self.lastError = String(describing: error)
+ self.busy = false
+ }
+ }
+ }
+ }
+}
+
+/// Minimal kqueue-backed directory watcher: fires on writes, adds, deletes.
+final class DirectoryWatcher {
+ private let source: DispatchSourceFileSystemObject
+
+ init?(path: String, onChange: @escaping () -> Void) {
+ let descriptor = open(path, O_EVTONLY)
+ guard descriptor >= 0 else { return nil }
+ source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor,
+ eventMask: [.write, .extend, .rename, .delete],
+ queue: DispatchQueue.global(qos: .utility)
+ )
+ source.setEventHandler(handler: onChange)
+ source.setCancelHandler { close(descriptor) }
+ source.resume()
+ }
+
+ deinit {
+ source.cancel()
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift
new file mode 100644
index 00000000..4c27402b
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/Models.swift
@@ -0,0 +1,340 @@
+import Foundation
+
+/// Decodable views over `lnk ... --json` payloads. Tolerant on purpose:
+/// only the fields the popover renders are required.
+
+struct MemoryInbox: Decodable {
+ let reviewCount: Int
+ let items: [InboxItem]
+
+ enum CodingKeys: String, CodingKey {
+ case reviewCount = "review_count"
+ case items
+ }
+}
+
+struct InboxItem: Decodable, Identifiable {
+ let name: String
+ let title: String
+ let memoryType: String
+ let tldr: String?
+ let highestSeverity: String?
+
+ var id: String { name }
+
+ enum CodingKeys: String, CodingKey {
+ case name, title, tldr
+ case memoryType = "memory_type"
+ case highestSeverity = "highest_severity"
+ }
+}
+
+struct CaptureInbox: Decodable {
+ let count: Int
+ let captures: [CaptureItem]
+}
+
+struct ProposalPreview: Decodable {
+ let title: String?
+ let memory: String?
+ let memoryType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case title, memory
+ case memoryType = "memory_type"
+ }
+}
+
+struct CaptureItem: Decodable, Identifiable {
+ let path: String
+ let title: String?
+ let project: String?
+ let proposals: [ProposalPreview]?
+ let snippet: String?
+ let decisionTrail: [String]?
+ let minedFromUserTurns: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case path, title, project, proposals, snippet
+ case decisionTrail = "decision_trail"
+ case minedFromUserTurns = "mined_from_user_turns"
+ }
+
+ var id: String { path }
+ var displayTitle: String {
+ title ?? (path as NSString).lastPathComponent
+ }
+
+ /// Capture filenames start with a UTC stamp (20260712T165329Z-…);
+ /// shown as local relative time ("2h ago") so same-titled sessions
+ /// disambiguate without the reader doing timezone math.
+ var capturedAt: String? {
+ let name = (path as NSString).lastPathComponent
+ guard name.count >= 16, name.prefix(8).allSatisfy(\.isNumber) else { return nil }
+ var comps = DateComponents()
+ comps.year = Int(name.prefix(4)); comps.month = Int(name.dropFirst(4).prefix(2))
+ comps.day = Int(name.dropFirst(6).prefix(2)); comps.hour = Int(name.dropFirst(9).prefix(2))
+ comps.minute = Int(name.dropFirst(11).prefix(2)); comps.second = Int(name.dropFirst(13).prefix(2))
+ comps.timeZone = TimeZone(identifier: "UTC")
+ guard let date = Calendar(identifier: .gregorian).date(from: comps) else { return nil }
+ return date.relativeLabel
+ }
+}
+
+extension Date {
+ /// Parse Link's UTC stamps ("2026-07-12T18:00:00Z").
+ static func fromLinkStamp(_ stamp: String) -> Date? {
+ ISO8601DateFormatter().date(from: stamp)
+ }
+
+ /// "2h ago" under a day, "Jul 12" beyond — compact enough for a row.
+ var relativeLabel: String {
+ let interval = Date().timeIntervalSince(self)
+ if interval < 90 { return "now" }
+ if interval < 3600 { return "\(Int(interval / 60))m ago" }
+ if interval < 86_400 { return "\(Int(interval / 3600))h ago" }
+ if interval < 7 * 86_400 { return "\(Int(interval / 86_400))d ago" }
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMM d"
+ return formatter.string(from: self)
+ }
+}
+
+struct MemoryLog: Decodable {
+ let entries: [LogEntry]
+}
+
+struct LogEntry: Decodable, Identifiable {
+ let timestamp: String
+ let operation: String
+ let description: String?
+
+ // timestamp+operation alone collides when one command writes twice
+ // in a second (ForEach then drops rows) — include the description.
+ var id: String { timestamp + operation + (description ?? "") }
+
+ var date: Date? { Date.fromLinkStamp(timestamp) }
+}
+
+struct RecallPayload: Decodable {
+ let memories: [RecalledMemory]
+ let abstention: Abstention?
+}
+
+struct Abstention: Decodable {
+ let recommended: Bool
+ let reason: String?
+}
+
+struct RecalledMemory: Decodable, Identifiable {
+ let name: String
+ let title: String
+ let memoryType: String?
+ let confidence: String?
+ let tldr: String?
+
+ var id: String { name }
+
+ enum CodingKeys: String, CodingKey {
+ case name, title, confidence, tldr
+ case memoryType = "memory_type"
+ }
+}
+
+struct StatusPayload: Decodable {
+ struct Warning: Decodable {
+ let code: String?
+ let message: String?
+ }
+
+ let version: String?
+ let warnings: [Warning]?
+ let memoryCount: Int?
+ let activeMemoryCount: Int?
+ let needsReviewCount: Int?
+ let contentPageCount: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case version, warnings
+ case memoryCount = "memory_count"
+ case activeMemoryCount = "active_memory_count"
+ case needsReviewCount = "needs_review_count"
+ case contentPageCount = "content_page_count"
+ }
+}
+
+struct RememberResult: Decodable {
+ let created: Bool
+ let secret: Bool?
+ let message: String?
+}
+
+// MARK: - Status dashboard payloads
+
+/// `lnk verify-mcp --json`: is the MCP server reachable and version-matched?
+struct MCPVerify: Decodable {
+ struct Component: Decodable {
+ let installed: Bool?
+ let version: String?
+ let mcpSdk: Bool?
+ let error: String?
+ enum CodingKeys: String, CodingKey {
+ case installed, version, error
+ case mcpSdk = "mcp_sdk"
+ }
+ }
+ struct Action: Decodable {
+ let label: String?
+ let commandText: String?
+ let command: [String]?
+ enum CodingKeys: String, CodingKey {
+ case label, command
+ case commandText = "command_text"
+ }
+ }
+
+ let ready: Bool
+ let python: String?
+ let expectedVersion: String?
+ let versionMatches: Bool?
+ let linkMcp: Component?
+ let nextActions: [Action]?
+
+ enum CodingKeys: String, CodingKey {
+ case ready, python
+ case expectedVersion = "expected_version"
+ case versionMatches = "version_matches"
+ case linkMcp = "link_mcp"
+ case nextActions = "next_actions"
+ }
+}
+
+/// `lnk semantic --json`: which recall power is active (lexical/fast/quality + rerank).
+struct SemanticStatus: Decodable {
+ let enabled: Bool
+ let tier: String?
+ let provider: String?
+ let mode: String?
+ let model: String?
+ let rerankReady: Bool?
+ let rerankState: String?
+ let modelAvailableOffline: Bool?
+ let indexedMemories: Int?
+ let memoryCount: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case enabled, tier, provider, mode, model
+ case rerankReady = "rerank_ready"
+ case rerankState = "rerank_state"
+ case modelAvailableOffline = "model_available_offline"
+ case indexedMemories = "indexed_memories"
+ case memoryCount = "memory_count"
+ }
+}
+
+/// One row in the Status dashboard: a Link surface and its live health.
+struct SurfaceHealth: Identifiable {
+ enum Level {
+ case ok, warn, error, info
+ var color: (r: Double, g: Double, b: Double) {
+ switch self {
+ case .ok: return (0.30, 0.72, 0.42) // green
+ case .warn: return (0.90, 0.62, 0.20) // amber
+ case .error: return (0.86, 0.30, 0.24) // red
+ case .info: return (0.55, 0.55, 0.55) // neutral
+ }
+ }
+ }
+ /// A one-click remediation for an unhealthy surface.
+ struct Fix {
+ let label: String
+ let action: () -> Void
+ }
+
+ let icon: String
+ let name: String
+ let level: Level
+ let detail: String
+ var fix: Fix? = nil
+
+ var id: String { name }
+}
+
+/// A live agent session detected from transcript activity — the "pulse".
+struct AgentSession: Identifiable {
+ let project: String
+ let lastActive: Date
+
+ var id: String { project }
+ var minutesAgo: Int { max(0, Int(Date().timeIntervalSince(lastActive) / 60)) }
+}
+
+/// A memory page read straight from wiki/memories/*.md — the browser shows
+/// your actual files, no intermediary. Frontmatter is simple `key: value`
+/// lines; we parse only what the browser displays.
+struct MemoryPage: Identifiable {
+ let name: String
+ let title: String
+ let memoryType: String
+ let scope: String
+ let project: String
+ let status: String // active | archived | …
+ let reviewStatus: String // reviewed | needs_review | …
+ let supersedes: String
+ let supersededBy: String
+ let body: String
+ let modified: Date
+
+ var id: String { name }
+ var isActive: Bool { status == "active" }
+
+ static func load(from workspace: String) -> [MemoryPage] {
+ let dir = (workspace as NSString).appendingPathComponent("wiki/memories")
+ let fm = FileManager.default
+ guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return [] }
+ var pages: [MemoryPage] = []
+ for file in files where file.hasSuffix(".md") {
+ let path = (dir as NSString).appendingPathComponent(file)
+ guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
+ var meta: [String: String] = [:]
+ var body = ""
+ let parts = text.components(separatedBy: "\n---")
+ if parts.count >= 2, text.hasPrefix("---") {
+ for line in parts[0].split(separator: "\n") {
+ guard let colon = line.firstIndex(of: ":") else { continue }
+ let key = line[.. $1.modified }
+ }
+
+ /// The memory claim itself: first non-heading body line.
+ var claim: String {
+ for line in body.split(separator: "\n") {
+ let t = line.trimmingCharacters(in: .whitespaces)
+ if t.isEmpty || t.hasPrefix("#") { continue }
+ return t
+ }
+ return title
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/Notifications.swift b/apps/LinkBar/Sources/LinkBar/Notifications.swift
new file mode 100644
index 00000000..2023252c
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/Notifications.swift
@@ -0,0 +1,126 @@
+import AppKit
+import UserNotifications
+
+/// Native notifications when a session capture lands, with an Accept action
+/// on the banner — the review gate meeting you the moment memory is proposed.
+///
+/// The open question is whether macOS grants notification authorization to an
+/// ad-hoc-signed (unsigned) app; UNUserNotificationCenter ties permission to
+/// the code signature. `probeAuthorization` answers that on the real machine
+/// so we know whether this feature is free or needs the $99 signed build.
+@MainActor
+final class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
+ static let shared = NotificationManager()
+
+ private let acceptAction = "LINK_ACCEPT"
+ private let reviewAction = "LINK_REVIEW"
+ private let captureCategory = "LINK_CAPTURE"
+ private weak var store: LinkStore?
+ /// Capture paths already announced, so a refresh burst never double-notifies.
+ private var announced: Set = []
+ private var primed = false
+ /// macOS grants notification authorization only to properly signed apps;
+ /// an ad-hoc/unsigned build is denied outright. When false, the whole
+ /// feature stays dormant (no dead API calls) and lights up automatically
+ /// once LinkBar ships signed + notarized.
+ private var authorized = false
+
+ func install(store: LinkStore) {
+ self.store = store
+ let center = UNUserNotificationCenter.current()
+ center.delegate = self
+
+ let accept = UNNotificationAction(identifier: acceptAction, title: "Accept", options: [])
+ let review = UNNotificationAction(identifier: reviewAction, title: "Review…", options: [.foreground])
+ let category = UNNotificationCategory(identifier: captureCategory,
+ actions: [accept, review],
+ intentIdentifiers: [],
+ options: [])
+ center.setNotificationCategories([category])
+ center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
+ Task { @MainActor in self.authorized = granted }
+ }
+ }
+
+ /// Seed `announced` with whatever is already in the inbox so the first
+ /// refresh after launch doesn't fire a banner for every old capture.
+ func prime(with captures: [CaptureItem]) {
+ guard !primed else { return }
+ announced.formUnion(captures.map(\.path))
+ primed = true
+ }
+
+ /// Called on each refresh with the current inbox; notifies once per new
+ /// capture path.
+ func announceNewCaptures(_ captures: [CaptureItem]) {
+ guard authorized else { return } // inert on unsigned builds
+ guard primed else { prime(with: captures); return }
+ for capture in captures where !announced.contains(capture.path) {
+ announced.insert(capture.path)
+ post(for: capture)
+ }
+ }
+
+ private func post(for capture: CaptureItem) {
+ let content = UNMutableNotificationContent()
+ content.title = "Link captured a memory"
+ if let first = capture.proposals?.first, let memory = first.memory {
+ content.subtitle = capture.displayTitle
+ content.body = "Will save: \(memory)"
+ } else {
+ content.body = capture.displayTitle
+ }
+ content.categoryIdentifier = captureCategory
+ content.userInfo = ["path": capture.path]
+ content.sound = nil
+
+ let request = UNNotificationRequest(identifier: capture.path, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request)
+ }
+
+ // Banner actions
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void) {
+ let path = response.notification.request.content.userInfo["path"] as? String
+ switch response.actionIdentifier {
+ case acceptAction:
+ if let path { Task { @MainActor in self.store?.acceptCaptureByPath(path) } }
+ case reviewAction, UNNotificationDefaultActionIdentifier:
+ NSApp.activate(ignoringOtherApps: true)
+ PaletteController.shared.hide()
+ default:
+ break
+ }
+ completionHandler()
+ }
+
+ // Show banners even while LinkBar is frontmost (it's a menu-bar agent).
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
+ completionHandler([.banner, .list])
+ }
+
+ /// Report the current authorization status — the ad-hoc-signing answer.
+ /// Invoked by `LINKBAR_NOTIFY_TEST=1` on the installed bundle.
+ func probeAuthorization(_ done: @escaping (String) -> Void) {
+ let center = UNUserNotificationCenter.current()
+ center.requestAuthorization(options: [.alert, .sound]) { granted, error in
+ center.getNotificationSettings { settings in
+ let status: String
+ switch settings.authorizationStatus {
+ case .authorized: status = "authorized"
+ case .denied: status = "denied"
+ case .notDetermined: status = "notDetermined"
+ case .provisional: status = "provisional"
+ case .ephemeral: status = "ephemeral"
+ @unknown default: status = "unknown"
+ }
+ let err = error.map { " error=\($0.localizedDescription)" } ?? ""
+ done("granted=\(granted) status=\(status)\(err)")
+ }
+ }
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/Palette.swift b/apps/LinkBar/Sources/LinkBar/Palette.swift
new file mode 100644
index 00000000..1cf17690
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/Palette.swift
@@ -0,0 +1,101 @@
+import AppKit
+import Carbon.HIToolbox
+import SwiftUI
+
+/// The Memory Palette: a global hotkey (⌥⌘M) that opens a floating panel
+/// over any app — type to recall memory, prefix with "+" to remember.
+/// Carbon's RegisterEventHotKey needs no permissions (unlike global event
+/// monitors, which require Input Monitoring), so this works in an
+/// unsigned/ad-hoc build.
+final class PaletteController {
+ static let shared = PaletteController()
+
+ private var panel: NSPanel?
+ private var hotKeyRef: EventHotKeyRef?
+ private weak var store: LinkStore?
+
+ func install(store: LinkStore) {
+ self.store = store
+ registerHotKey()
+ }
+
+ // MARK: hotkey (⌥⌘M)
+
+ private func registerHotKey() {
+ let signature = OSType(0x4C4E4B42) // "LNKB"
+ var hotKeyID = EventHotKeyID(signature: signature, id: 1)
+ var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
+ eventKind: UInt32(kEventHotKeyPressed))
+ InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
+ DispatchQueue.main.async { PaletteController.shared.toggle() }
+ return noErr
+ }, 1, &eventType, nil, nil)
+ RegisterEventHotKey(UInt32(kVK_ANSI_M),
+ UInt32(optionKey | cmdKey),
+ hotKeyID,
+ GetApplicationEventTarget(),
+ 0,
+ &hotKeyRef)
+ }
+
+ // MARK: panel lifecycle
+
+ func toggle() {
+ if let panel, panel.isVisible {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ func show() {
+ guard let store else { return }
+ let panel = self.panel ?? makePanel(store: store)
+ self.panel = panel
+ // Center on the screen with the mouse (multi-display friendly).
+ if let screen = NSScreen.screens.first(where: { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) }) ?? NSScreen.main {
+ let frame = screen.frame
+ let size = panel.frame.size
+ panel.setFrameOrigin(NSPoint(
+ x: frame.midX - size.width / 2,
+ y: frame.midY - size.height / 2 + frame.height * 0.12
+ ))
+ }
+ panel.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ func hide() {
+ panel?.orderOut(nil)
+ }
+
+ private func makePanel(store: LinkStore) -> NSPanel {
+ let panel = KeyablePanel(
+ contentRect: NSRect(x: 0, y: 0, width: 560, height: 100),
+ styleMask: [.titled, .fullSizeContentView, .nonactivatingPanel],
+ backing: .buffered, defer: false
+ )
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ panel.isMovableByWindowBackground = true
+ panel.level = .floating
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.isFloatingPanel = true
+ panel.hidesOnDeactivate = false
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = true
+
+ let host = NSHostingView(rootView: PaletteView(dismiss: { [weak self] in self?.hide() })
+ .environmentObject(store))
+ panel.contentView = host
+ return panel
+ }
+}
+
+/// NSPanel refuses key status by default with .nonactivatingPanel; the
+/// palette needs it so the search field can accept typing immediately.
+final class KeyablePanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+ override func cancelOperation(_ sender: Any?) { orderOut(nil) } // Esc closes
+}
diff --git a/apps/LinkBar/Sources/LinkBar/PaletteView.swift b/apps/LinkBar/Sources/LinkBar/PaletteView.swift
new file mode 100644
index 00000000..64e167fe
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/PaletteView.swift
@@ -0,0 +1,181 @@
+import SwiftUI
+
+/// The floating palette content: one field that recalls as you type and
+/// remembers when you prefix with "+". Enter copies the top recall result
+/// (or saves, in remember mode); Esc closes.
+struct PaletteView: View {
+ @EnvironmentObject var store: LinkStore
+ let dismiss: () -> Void
+
+ @State private var text = ""
+ @State private var results: [RecalledMemory] = []
+ @State private var abstention: Abstention?
+ @State private var confirmation: String?
+ @State private var searching = false
+ @FocusState private var focused: Bool
+ @State private var debounce: DispatchWorkItem?
+
+ private var isRemember: Bool { text.hasPrefix("+") }
+ private var payload: String {
+ isRemember ? String(text.dropFirst()).trimmingCharacters(in: .whitespaces)
+ : text.trimmingCharacters(in: .whitespaces)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ field
+ if let confirmation {
+ confirmationRow(confirmation)
+ } else if isRemember {
+ rememberHint
+ } else if !results.isEmpty {
+ resultsList
+ } else if abstention?.recommended == true {
+ emptyRow("Nothing reliable on this — the honest answer is \u{201C}don't know.\u{201D}", "hand.raised")
+ } else if !payload.isEmpty && !searching {
+ emptyRow("No memory matches \u{201C}\(payload)\u{201D}.", "questionmark.circle")
+ }
+ }
+ .frame(width: 560)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
+ .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.08)))
+ .onAppear { focused = true }
+ }
+
+ // MARK: field
+
+ private var field: some View {
+ HStack(spacing: 10) {
+ Image(systemName: isRemember ? "plus.circle.fill" : "magnifyingglass")
+ .font(.system(size: 15))
+ .foregroundStyle(isRemember ? AnyShapeStyle(LinkBrand.rust) : AnyShapeStyle(.secondary))
+ TextField("Ask your memory… (start with + to remember)", text: $text)
+ .textFieldStyle(.plain)
+ .font(.system(size: 17))
+ .focused($focused)
+ .onSubmit(commit)
+ .onChange(of: text) { _, _ in
+ confirmation = nil
+ scheduleRecall()
+ }
+ if searching {
+ ProgressView().controlSize(.small)
+ }
+ Text(isRemember ? "return to save" : (results.isEmpty ? "" : "return to copy"))
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 16).padding(.vertical, 14)
+ }
+
+ // MARK: recall results
+
+ private var resultsList: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider().opacity(0.3)
+ ForEach(Array(results.prefix(5).enumerated()), id: \.element.id) { index, memory in
+ Button {
+ store.copyText(memory.tldr ?? memory.title)
+ confirm("Copied to clipboard")
+ } label: {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(index == 0 ? "\u{21A9}" : "\u{00B7}")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(index == 0 ? AnyShapeStyle(LinkBrand.rust) : AnyShapeStyle(.tertiary))
+ .frame(width: 12)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(memory.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
+ if let tldr = memory.tldr {
+ Text(tldr).font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ }
+ }
+ Spacer()
+ if let c = memory.confidence { confidenceChip(c) }
+ }
+ .padding(.horizontal, 16).padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.bottom, 6)
+ }
+
+ private var rememberHint: some View {
+ HStack(spacing: 8) {
+ Divider().opacity(0)
+ Text(payload.isEmpty
+ ? "Type what to remember — saved as pending your review."
+ : "Saves \u{201C}\(payload)\u{201D} for review. Nothing is trusted until you approve it.")
+ .font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 16).padding(.bottom, 12)
+ }
+
+ private func confirmationRow(_ message: String) -> some View {
+ HStack(spacing: 6) {
+ Circle().fill(Color.green).frame(width: 5, height: 5)
+ Text(message).font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 16).padding(.bottom, 12)
+ }
+
+ private func emptyRow(_ message: String, _ icon: String) -> some View {
+ Label(message, systemImage: icon)
+ .font(.caption).foregroundStyle(.secondary)
+ .padding(.horizontal, 16).padding(.bottom, 12)
+ }
+
+ private func confidenceChip(_ value: String) -> some View {
+ let color: Color = value == "strong" ? .green : (value == "moderate" ? .orange : .gray)
+ return Text(value)
+ .font(.system(size: 9, weight: .medium))
+ .foregroundStyle(color)
+ .padding(.horizontal, 5).padding(.vertical, 1)
+ .background(color.opacity(0.13), in: Capsule())
+ }
+
+ // MARK: actions
+
+ private func scheduleRecall() {
+ debounce?.cancel()
+ guard !isRemember, !payload.isEmpty else { results = []; abstention = nil; return }
+ searching = true
+ let work = DispatchWorkItem {
+ store.paletteRecall(payload) { mems, abst in
+ results = mems; abstention = abst; searching = false
+ }
+ }
+ debounce = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
+ }
+
+ private func commit() {
+ if isRemember {
+ guard !payload.isEmpty else { return }
+ store.paletteRemember(payload) { result in
+ if result?.created == true {
+ confirm("Saved — pending your review")
+ } else if result?.secret == true {
+ confirm("Not saved — looks like a secret. Use a password manager.")
+ } else {
+ confirm("Not saved — a similar or conflicting memory exists.")
+ }
+ }
+ } else if let top = results.first {
+ store.copyText(top.tldr ?? top.title)
+ confirm("Copied to clipboard")
+ }
+ }
+
+ /// Show a confirmation, then auto-dismiss the panel shortly after.
+ private func confirm(_ message: String) {
+ confirmation = message
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) {
+ text = ""; results = []; confirmation = nil
+ dismiss()
+ }
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift
new file mode 100644
index 00000000..f8f0893f
--- /dev/null
+++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift
@@ -0,0 +1,762 @@
+import SwiftUI
+
+struct PopoverView: View {
+ enum Tab { case inbox, memory, status, settings }
+
+ @EnvironmentObject var store: LinkStore
+ @State private var query = ""
+ @State private var memoryQuery = ""
+ @State private var memoryTypeFilter: String? = nil
+ @State private var showArchived = false
+ // Snapshot aid: LINKBAR_TAB=status opens straight to the Status tab.
+ @State private var tab: Tab =
+ ProcessInfo.processInfo.environment["LINKBAR_TAB"] == "status" ? .status
+ : ProcessInfo.processInfo.environment["LINKBAR_TAB"] == "memory" ? .memory : .inbox
+
+ private var isFullyIdle: Bool {
+ (store.inbox?.items.isEmpty ?? true)
+ && (store.captures?.captures.isEmpty ?? true)
+ && store.recallResults.isEmpty
+ && store.searchedQuery == nil
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ header
+ .padding(.horizontal, LinkBrand.pad)
+ .padding(.top, LinkBrand.pad)
+ tabBar
+ .padding(.horizontal, LinkBrand.pad)
+ .padding(.top, 10)
+ Divider().opacity(0.35).padding(.top, 8)
+
+ Group {
+ switch tab {
+ case .inbox: inboxTab
+ case .memory: memoryTab
+ case .status: statusTab
+ case .settings:
+ SettingsPane(done: { tab = .inbox })
+ .environmentObject(store)
+ }
+ }
+ .animation(.easeOut(duration: 0.18), value: store.pendingCount)
+
+ footer
+ .padding(.horizontal, LinkBrand.pad)
+ .padding(.bottom, LinkBrand.pad)
+ }
+ .frame(width: 380)
+ }
+
+ // MARK: tab bar
+
+ private var tabBar: some View {
+ HStack(spacing: 4) {
+ tabButton("Inbox", .inbox, badge: store.pendingCount)
+ tabButton("Memory", .memory)
+ tabButton("Status", .status, alert: store.anyUnhealthy)
+ tabButton("Settings", .settings)
+ Spacer()
+ }
+ }
+
+ private func tabButton(_ label: String, _ value: Tab, badge: Int = 0, alert: Bool = false) -> some View {
+ let active = tab == value
+ return Button {
+ tab = value
+ if value == .status { store.refreshHealth() }
+ } label: {
+ HStack(spacing: 4) {
+ Text(label)
+ .font(.system(size: 12, weight: active ? .semibold : .regular))
+ if badge > 0 {
+ Text("\(badge)")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 4).padding(.vertical, 1)
+ .background(LinkBrand.rust, in: Capsule())
+ } else if alert {
+ Circle().fill(Color(red: 0.90, green: 0.62, blue: 0.20)).frame(width: 6, height: 6)
+ }
+ }
+ .foregroundStyle(active ? AnyShapeStyle(LinkBrand.rust) : AnyShapeStyle(.secondary))
+ .padding(.horizontal, 9).padding(.vertical, 4)
+ .background(active ? AnyShapeStyle(LinkBrand.rust.opacity(0.12)) : AnyShapeStyle(.clear),
+ in: RoundedRectangle(cornerRadius: 7))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: inbox tab (the original memory view)
+
+ private var inboxTab: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.betweenSections) {
+ if !store.activeSessions.isEmpty {
+ pulseRow
+ }
+ if let warning = store.runtimeWarning {
+ runtimeBanner(warning)
+ }
+ recallField
+ if store.searchedQuery != nil || !store.recallResults.isEmpty {
+ recallSection
+ }
+ if isFullyIdle {
+ idleState
+ } else {
+ inboxSection
+ if let captures = store.captures, !captures.captures.isEmpty {
+ capturesSection(captures)
+ }
+ }
+ if !store.activity.isEmpty && !isFullyIdle {
+ activitySection
+ }
+ }
+ .padding(LinkBrand.pad)
+ }
+
+ /// Live agent pulse: which sessions are writing transcripts right now,
+ /// and when memory last changed — the ambient "memory is being made" row.
+ private var pulseRow: some View {
+ HStack(spacing: 8) {
+ PulseDot()
+ Text(pulseText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ Spacer()
+ if let last = store.activity.first?.date {
+ Text("memory · \(last.relativeLabel)")
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.horizontal, 10).padding(.vertical, 7)
+ .background(Color.green.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ private var pulseText: String {
+ let sessions = store.activeSessions
+ let names = sessions.prefix(3).map(\.project).joined(separator: ", ")
+ let count = sessions.count == 1 ? "1 agent" : "\(sessions.count) agents"
+ return "\(count) active · \(names)"
+ }
+
+ // MARK: memory tab (browse every memory file)
+
+ private var filteredMemories: [MemoryPage] {
+ store.memories.filter { page in
+ (showArchived || page.isActive)
+ && (memoryTypeFilter == nil || page.memoryType == memoryTypeFilter)
+ && (memoryQuery.isEmpty
+ || page.title.localizedCaseInsensitiveContains(memoryQuery)
+ || page.claim.localizedCaseInsensitiveContains(memoryQuery))
+ }
+ }
+
+ private var memoryTab: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ HStack(spacing: 5) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 11)).foregroundStyle(.tertiary)
+ TextField("Filter memories…", text: $memoryQuery)
+ .textFieldStyle(.plain).font(.system(size: 12.5))
+ if !memoryQuery.isEmpty {
+ Button { memoryQuery = "" } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 11)).foregroundStyle(.tertiary)
+ }.buttonStyle(.borderless)
+ }
+ }
+ .padding(.horizontal, 8).padding(.vertical, 6)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 7))
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 5) {
+ filterChip("all", nil)
+ ForEach(["preference", "decision", "note", "project", "procedure", "fact"], id: \.self) { t in
+ filterChip(t, t)
+ }
+ Divider().frame(height: 14)
+ Button { showArchived.toggle() } label: {
+ Text("archived")
+ .font(.system(size: 10.5, weight: showArchived ? .semibold : .regular))
+ .foregroundStyle(showArchived ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
+ .padding(.horizontal, 8).padding(.vertical, 3)
+ .background(showArchived ? AnyShapeStyle(.quaternary.opacity(0.7)) : AnyShapeStyle(.clear), in: Capsule())
+ }.buttonStyle(.plain)
+ }
+ }
+
+ SectionHeader(title: "Memories", count: filteredMemories.count)
+ if filteredMemories.isEmpty {
+ Label(store.memories.isEmpty ? "No memories yet — say something worth keeping."
+ : "Nothing matches this filter.",
+ systemImage: "tray")
+ .font(.caption).foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ }
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 2) {
+ ForEach(filteredMemories) { page in
+ memoryRow(page)
+ }
+ }
+ }
+ .frame(maxHeight: 320)
+ }
+ .padding(LinkBrand.pad)
+ }
+
+ private func filterChip(_ label: String, _ value: String?) -> some View {
+ let active = memoryTypeFilter == value
+ return Button { memoryTypeFilter = value } label: {
+ Text(label)
+ .font(.system(size: 10.5, weight: active ? .semibold : .regular))
+ .foregroundStyle(active ? AnyShapeStyle(Color.white) : AnyShapeStyle(.secondary))
+ .padding(.horizontal, 8).padding(.vertical, 3)
+ .background(active ? AnyShapeStyle(LinkBrand.rust) : AnyShapeStyle(.quaternary.opacity(0.5)), in: Capsule())
+ }.buttonStyle(.plain)
+ }
+
+ private func memoryRow(_ page: MemoryPage) -> some View {
+ HoverRow {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(page.title)
+ .font(.system(size: 12.5, weight: .medium))
+ .lineLimit(1)
+ .foregroundStyle(page.isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
+ Spacer()
+ if !page.supersededBy.isEmpty {
+ Text("superseded")
+ .font(.system(size: 9)).foregroundStyle(.orange)
+ } else if page.reviewStatus == "needs_review" {
+ Text("review")
+ .font(.system(size: 9)).foregroundStyle(LinkBrand.rust)
+ }
+ Text(page.modified.relativeLabel)
+ .font(.system(size: 9.5)).foregroundStyle(.quaternary)
+ }
+ HStack(spacing: 6) {
+ Text(page.memoryType).font(.system(size: 10)).foregroundStyle(LinkBrand.rust)
+ Text(page.scope + (page.project.isEmpty ? "" : " · \(page.project)"))
+ .font(.system(size: 10)).foregroundStyle(.tertiary)
+ if !page.supersedes.isEmpty {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.system(size: 8.5)).foregroundStyle(.tertiary)
+ .help("Supersedes: \(page.supersedes)")
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button("Copy text") { store.copyText(page.claim) }
+ Button("Reveal in Finder") { store.revealMemory(named: page.name) }
+ Divider()
+ if page.isActive {
+ Button("Archive") { store.archiveMemory(named: page.name) }
+ } else {
+ Button("Restore") { store.restoreMemory(named: page.name) }
+ }
+ }
+ .onTapGesture(count: 2) { store.revealMemory(named: page.name) }
+ }
+ .help(page.claim)
+ }
+
+ // MARK: status tab (health of every Link surface)
+
+ private var statusTab: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ HStack {
+ SectionHeader(title: "Link status")
+ Spacer()
+ Text(store.anyUnhealthy ? "Needs attention" : "All systems go")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(store.anyUnhealthy
+ ? Color(red: 0.90, green: 0.62, blue: 0.20) : .green)
+ }
+ ForEach(store.surfaces()) { surface in
+ StatusRow(surface: surface)
+ }
+ }
+ .padding(LinkBrand.pad)
+ }
+
+ // MARK: header
+
+ private var header: some View {
+ HStack(spacing: 2) {
+ Wordmark()
+ Spacer()
+ if store.busy {
+ ProgressView().controlSize(.small).padding(.trailing, 4)
+ }
+ toolbarButton("doc.on.clipboard", help: "Remember clipboard — saved as pending review") {
+ store.rememberClipboard()
+ }
+ toolbarButton("gauge.with.needle", help: "Open the Memory Dashboard") {
+ store.openDashboard()
+ }
+ toolbarButton("folder", help: "Open the workspace in Finder") {
+ store.openWorkspace()
+ }
+ toolbarButton("arrow.clockwise", help: "Refresh (⌘R)") {
+ store.refresh()
+ store.refreshHealth()
+ }
+ .keyboardShortcut("r", modifiers: .command)
+ }
+ }
+
+ private func toolbarButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: symbol)
+ .font(.system(size: 12, weight: .medium))
+ .frame(width: 24, height: 22)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.secondary)
+ .help(help)
+ }
+
+ /// The post-upgrade drift warning, with its one-click repair.
+ private func runtimeBanner(_ warning: String) -> some View {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.yellow)
+ .font(.caption)
+ Text(warning)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 4)
+ Button("Fix") { store.repairRuntime() }
+ .controlSize(.small)
+ .tint(LinkBrand.rust)
+ .help("Refresh the workspace runtime (lnk init)")
+ }
+ .padding(10)
+ .background(.yellow.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ // MARK: ask / remember
+
+ private var recallField: some View {
+ HStack(spacing: 6) {
+ HStack(spacing: 5) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 11))
+ .foregroundStyle(.tertiary)
+ TextField("Ask your memory — or type something to keep…", text: $query)
+ .textFieldStyle(.plain)
+ .font(.system(size: 12.5))
+ .onSubmit { store.recall(query) }
+ if !query.isEmpty || store.searchedQuery != nil {
+ Button {
+ query = ""
+ store.clearSearch()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 11))
+ .foregroundStyle(.tertiary)
+ }
+ .buttonStyle(.borderless)
+ .help("Clear the search")
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 7))
+ Button {
+ store.rememberText(query)
+ query = ""
+ } label: {
+ Image(systemName: "plus.circle.fill")
+ .font(.system(size: 17))
+ .foregroundStyle(
+ query.trimmingCharacters(in: .whitespaces).isEmpty
+ ? AnyShapeStyle(.quaternary) : AnyShapeStyle(LinkBrand.rust)
+ )
+ }
+ .buttonStyle(.borderless)
+ .keyboardShortcut(.return, modifiers: .command)
+ .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
+ .help("Remember this text (⌘↩) — saved as pending review")
+ }
+ }
+
+ private var recallSection: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ SectionHeader(title: "Recall", count: store.recallResults.isEmpty ? nil : store.recallResults.count)
+ if store.recallResults.isEmpty, store.abstention?.recommended != true,
+ let searched = store.searchedQuery {
+ Label("No matches for \u{201C}\(searched)\u{201D} — try different words.", systemImage: "questionmark.circle")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ }
+ if let abstention = store.abstention, abstention.recommended {
+ Label("Nothing reliable on this — the honest answer is \u{201C}don't know\u{201D}.", systemImage: "hand.raised")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ }
+ ForEach(store.recallResults.prefix(4)) { memory in
+ HoverRow {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(memory.title)
+ .font(.system(size: 12.5, weight: .medium))
+ .lineLimit(1)
+ Spacer()
+ if let confidence = memory.confidence {
+ confidenceChip(confidence)
+ }
+ }
+ if let tldr = memory.tldr {
+ Text(tldr).font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button("Copy text") { store.copyText(memory.tldr ?? memory.title) }
+ Button("Reveal in Finder") { store.revealMemory(named: memory.name) }
+ }
+ .onTapGesture(count: 2) { store.revealMemory(named: memory.name) }
+ }
+ .help("Double-click to open · right-click to copy")
+ }
+ }
+ }
+
+ private func confidenceChip(_ value: String) -> some View {
+ Text(value)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(confidenceColor(value))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1.5)
+ .background(confidenceColor(value).opacity(0.13))
+ .clipShape(Capsule())
+ }
+
+ // MARK: review inbox
+
+ private var inboxSection: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ let items = store.inbox?.items ?? []
+ SectionHeader(
+ title: "Review inbox",
+ count: store.inbox?.reviewCount ?? 0,
+ highlight: (store.inbox?.reviewCount ?? 0) > 0
+ )
+ if items.isEmpty {
+ Label("Nothing waiting — your memory is fully reviewed.", systemImage: "checkmark.seal")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ }
+ ForEach(items.prefix(5)) { item in
+ HoverRow {
+ HStack(alignment: .center, spacing: 8) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.title)
+ .font(.system(size: 12.5, weight: .medium))
+ .lineLimit(1)
+ HStack(spacing: 6) {
+ Text(item.memoryType)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LinkBrand.rust)
+ if let tldr = item.tldr {
+ Text(tldr).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)
+ }
+ }
+ }
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button("Reveal in Finder") { store.revealMemory(named: item.name) }
+ }
+ Spacer(minLength: 6)
+ Button {
+ store.markReviewed(item)
+ } label: { Image(systemName: "checkmark") }
+ .buttonStyle(.borderedProminent)
+ .tint(LinkBrand.rust)
+ .controlSize(.small)
+ .help("Mark reviewed — confirm this memory is accurate")
+ Button {
+ store.archive(item)
+ } label: { Image(systemName: "archivebox") }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .help("Archive — keep it out of recall, never deleted")
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: captures
+
+ private func capturesSection(_ captures: CaptureInbox) -> some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ SectionHeader(title: "Session captures", count: captures.count, highlight: captures.count > 0)
+ ForEach(captures.captures.prefix(3)) { capture in
+ HoverRow {
+ HStack(alignment: .center, spacing: 8) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(capture.displayTitle)
+ .font(.system(size: 12.5, weight: .medium))
+ .lineLimit(1)
+ if let stamp = capture.capturedAt {
+ Text(stamp).font(.system(size: 10.5)).foregroundStyle(.tertiary)
+ }
+ }
+ if let first = capture.proposals?.first, let memory = first.memory {
+ (Text("Will save: ").foregroundStyle(.tertiary)
+ + Text(memory).foregroundStyle(.secondary))
+ .font(.caption)
+ .lineLimit(2)
+ } else if let snippet = capture.snippet, !snippet.isEmpty {
+ Text(snippet)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ if let trail = capture.decisionTrail, !trail.isEmpty {
+ DisclosureGroup {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(Array(trail.enumerated()), id: \.offset) { _, step in
+ Text("• \(step)")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(.top, 2)
+ } label: {
+ Text("How Link read this session")
+ .font(.caption2)
+ .foregroundStyle(LinkBrand.rust)
+ }
+ }
+ }
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button("Reveal in Finder") { store.revealCapture(capture) }
+ }
+ Spacer(minLength: 6)
+ if let proposals = capture.proposals, proposals.count > 1 {
+ Menu("Accept") {
+ ForEach(Array(proposals.enumerated()), id: \.offset) { position, proposal in
+ Button(proposal.title ?? proposal.memory?.prefix(60).description ?? "Proposal \(position + 1)") {
+ store.acceptCapture(capture, index: position + 1)
+ }
+ }
+ }
+ .menuStyle(.borderedButton)
+ .controlSize(.small)
+ .frame(width: 76)
+ .help("This session proposed \(proposals.count) memories — pick one to accept")
+ } else {
+ Button("Accept") { store.acceptCapture(capture) }
+ .buttonStyle(.borderedProminent)
+ .tint(LinkBrand.rust)
+ .controlSize(.small)
+ .help("Accept the proposal into reviewed memory")
+ }
+ Button {
+ store.deleteCapture(capture)
+ } label: { Image(systemName: "trash") }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .help("Discard this capture")
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: activity, idle, footer
+
+ private var activitySection: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ SectionHeader(title: "Recent activity")
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(store.activity.prefix(3)) { entry in
+ HStack(spacing: 6) {
+ Text(entry.operation)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(.quaternary.opacity(0.6))
+ .clipShape(Capsule())
+ Text(entry.description ?? "")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.horizontal, 8)
+ }
+ }
+
+ /// Everything reviewed, nothing captured, nothing searched — show the
+ /// workspace at a glance instead of an empty room: counts, the last
+ /// fortnight's pulse, and the most recent thing Link did.
+ private var idleState: some View {
+ VStack(alignment: .leading, spacing: LinkBrand.inGroup) {
+ SectionHeader(title: "Your memory")
+ HStack(spacing: 0) {
+ StatChip(
+ value: "\(store.stats?.activeMemoryCount ?? 0)",
+ label: "active",
+ tint: LinkBrand.rust
+ )
+ StatChip(value: "\(store.stats?.memoryCount ?? 0)", label: "memories")
+ StatChip(value: "\(store.stats?.contentPageCount ?? 0)", label: "wiki pages")
+ StatChip(value: "\(store.stats?.needsReviewCount ?? 0)", label: "to review")
+ }
+ .padding(.vertical, 6)
+ HStack(alignment: .center, spacing: 10) {
+ Sparkline(values: store.activityPulse())
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Last 14 days")
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(.tertiary)
+ if let last = store.activity.first {
+ Text("Latest: \(last.operation)\(last.date.map { " · \($0.relativeLabel)" } ?? "")")
+ .font(.system(size: 9.5))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ Image(systemName: "checkmark.seal")
+ .font(.system(size: 15, weight: .light))
+ .foregroundStyle(LinkBrand.rust.opacity(0.75))
+ .help("Everything is reviewed")
+ }
+ .padding(.horizontal, 8)
+ }
+ }
+
+ private var footer: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let flash = store.flash {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(store.flashTone == .success ? Color.green : Color.secondary)
+ .frame(width: 5, height: 5)
+ Text(flash)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(.quaternary.opacity(0.5), in: Capsule())
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ }
+ if let error = store.lastError {
+ Text(error).font(.caption2).foregroundStyle(.red).lineLimit(2)
+ }
+ Divider()
+ HStack {
+ Text(LinkCLI.workspace)
+ .font(.system(size: 10.5))
+ .foregroundStyle(.quaternary)
+ .lineLimit(1)
+ .truncationMode(.head)
+ Spacer()
+ Text("LinkBar \(LinkBrand.version)" + (store.linkVersion.isEmpty ? "" : " · Link \(store.linkVersion)"))
+ .font(.system(size: 10.5))
+ .foregroundStyle(.tertiary)
+ Button("Quit") { NSApplication.shared.terminate(nil) }
+ .buttonStyle(.borderless)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .keyboardShortcut("q", modifiers: .command)
+ }
+ }
+ .animation(.easeOut(duration: 0.2), value: store.flash)
+ }
+
+ private func confidenceColor(_ value: String) -> Color {
+ switch value {
+ case "strong": return .green
+ case "moderate": return .orange
+ default: return .gray
+ }
+ }
+}
+
+struct SettingsPane: View {
+ @EnvironmentObject var store: LinkStore
+ let done: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 7) {
+ Wordmark()
+ Text("Settings")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ Toggle(isOn: Binding(
+ get: { store.launchAtLogin },
+ set: { store.setLaunchAtLogin($0) }
+ )) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Launch at login")
+ Text("Keep the review gate in your menu bar.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .tint(LinkBrand.rust)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Workspace").font(.subheadline)
+ Text(LinkCLI.workspace)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ Text("Set LINK_WORKSPACE to point LinkBar at a different workspace.")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Live updates").font(.subheadline)
+ Text("LinkBar refreshes the moment your workspace changes — session hooks, agents, and edits all show up instantly.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ HStack {
+ if !store.linkVersion.isEmpty {
+ Text("LinkBar \(LinkBrand.version) · Link \(store.linkVersion)")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ Spacer()
+ Button("Done") { done() }
+ .keyboardShortcut(.defaultAction)
+ .tint(LinkBrand.rust)
+ }
+ }
+ .padding(16)
+ }
+}
diff --git a/apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns b/apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns
new file mode 100644
index 00000000..c04e7d22
Binary files /dev/null and b/apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns differ
diff --git a/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon.png b/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon.png
new file mode 100644
index 00000000..b7221053
Binary files /dev/null and b/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon.png differ
diff --git a/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon@2x.png b/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon@2x.png
new file mode 100644
index 00000000..5e0a200c
Binary files /dev/null and b/apps/LinkBar/Sources/LinkBar/Resources/MenuIcon@2x.png differ
diff --git a/docs/assets/linkbar-inbox.png b/docs/assets/linkbar-inbox.png
new file mode 100644
index 00000000..e15d0c0a
Binary files /dev/null and b/docs/assets/linkbar-inbox.png differ
diff --git a/docs/assets/linkbar-status.png b/docs/assets/linkbar-status.png
new file mode 100644
index 00000000..92fe07ac
Binary files /dev/null and b/docs/assets/linkbar-status.png differ
diff --git a/docs/getting-started.html b/docs/getting-started.html
index 56819175..74822778 100644
--- a/docs/getting-started.html
+++ b/docs/getting-started.html
@@ -208,14 +208,19 @@ 9. Make The Loop Automatic (Session Hooks)
lnk connect cursor ~/link --hooks --write
Codex and Cursor hook support is new and follows those vendors' documented hook schemas; if a hook misbehaves there, please open an issue.
- 10. Optional: Hybrid Semantic Recall
+ 10. Optional: LinkBar, the menu bar app (macOS)
+ Hooks make capture automatic; LinkBar makes reviewing it ambient. It puts the review gate in your menu bar: proposals arrive as a native notification with a one-tap Accept, a global palette (⌥⌘M) recalls or remembers from any app, a live pulse shows which agent sessions are writing right now, and a Status tab reports the health of every Link surface (CLI, workspace, MCP, hooks, recall tier, viewer) with one-click fixes. It runs the same reviewed lnk commands you just wired — nothing new to trust.
+ brew install --cask gowtham0992/link/linkbar
+ LinkBar ships unsigned (no Apple Developer certificate); the cask clears the quarantine flag on install so it opens like any other app. Prefer building it yourself: cd apps/LinkBar && bash Scripts/bundle.sh --install.
+
+ 11. Optional: Hybrid Semantic Recall
Lexical recall is always the default and the fallback. Two optional local tiers add paraphrase recall — "how should I structure my pull requests" finds a memory about commit style. The models load offline-only at recall time (a query can never trigger a download), embeddings are plain JSON under .link-cache/, and there is no vector database or service.
lnk semantic ~/link --setup # installs the extras if needed + fetches the models once
python3 -m link_mcp --semantic-setup --wiki ~/link/wiki # MCP-only installs
On Homebrew installs the runtime Python refuses direct pip installs (PEP 668), so --setup provisions the extras into Link's managed venv (~/.link-mcp-venv) automatically — the same venv the MCP server runs from, and lnk uses it on the next command. In your own venv, pip install "link-mcp[semantic]" (fast tier) or "link-mcp[semantic-quality]" (quality tier) works as usual before running --setup.
Recall quality is measured, not asserted: see benchmarks/RESULTS.md for the full methodology, numbers, and honest limitations.
- 11. The Three Questions Everyone Asks
+ 12. The Three Questions Everyone Asks
Does Link read my conversations?
Link never sees a conversation. The session hooks do the reading: at session end the agent's own transcript is mined locally and deterministically — no LLM, no network — for statements that look like durable preferences or decisions ("from now on I only push to develop"). Those become proposals in a pending inbox. Nothing is memory until you approve it, and the whole pipeline runs from plain Python files inside your own workspace that you can read (link_core/agent_hooks.py). If you never install hooks, Link only knows what you explicitly tell it with lnk remember.
diff --git a/docs/index.html b/docs/index.html
index 69bf9980..f1dd198a 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -197,7 +197,7 @@ Link — local memory for AI agents (PyPI: link-mcp)