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 @@ CI MCP Registry PyPI + PyPI Downloads

@@ -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. + +

+ LinkBar inbox: live agent pulse, session captures with Will-save previews and decision trails + LinkBar status: health dots for CLI, workspace, MCP, hooks, recall tier, and viewer with one-click fixes +

+ +```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

+ +

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)

diff --git a/link.py b/link.py index 2ad318d2..f074130b 100644 --- a/link.py +++ b/link.py @@ -2061,10 +2061,11 @@ def semantic(target: Path, setup: bool = False, rebuild: bool = False, json_outp if not wiki_dir.exists(): return _missing_wiki_error(wiki_dir) records = _memory_records(wiki_dir) - active_count = len([ - record for record in records - if str(record.get("status") or "active").lower() == "active" - ]) + # The index embeds every memory (archived included — they stay inert in + # default recall but let --include-archived benefit from semantic + # matching), so the "Indexed X of Y" denominator must be the total, + # not the active count: 16-of-6 was nonsense. + total_count = len(records) action_error = "" action_result = "" if setup or rebuild: @@ -2131,7 +2132,7 @@ def semantic(target: Path, setup: bool = False, rebuild: bool = False, json_outp ) payload = _core_build_semantic_status( root, - memory_count=active_count, + memory_count=total_count, command_target=root, python_cmd=sys.executable, externally_managed=_core_python_is_externally_managed(), diff --git a/mcp_package/README.md b/mcp_package/README.md index 2d7f9405..97759ad0 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -186,6 +186,26 @@ vector database or service. Semantic-only matches are labeled (`match: semantic`, capped confidence) so agents verify before trusting them. Measured results: . +## Benchmarks + +Plain files with no LLM in the memory layer, measured against the systems +that have one everywhere: + +- **LoCoMo end-to-end QA** (all 1,540 questions, under mem0's own open + harness): **84.8%** vs mem0's cloud platform at 83.2% under the same + judge — confirmed by a second, independent judge (Tencent Hunyuan 3): + 85.5% vs 83.6%. +- **LongMemEval evidence retrieval**: the memory layer put the gold + evidence in context for **99.4%** of 500 questions (deterministic, no + LLM judge). +- **Memory hygiene**: **0%** junk stored over a simulated multi-month + session stream (CI-enforced); the same pipeline with governance off + stores 23.9%. + +Every number is published with its config, judge models, caveats, and the +experiments that lost: +[benchmarks/RESULTS.md](https://github.com/gowtham0992/link/blob/main/benchmarks/RESULTS.md). + ## Privacy and Scale - Local-first: `link-mcp` reads the wiki path you configure and does not call diff --git a/mcp_package/link_core/version.py b/mcp_package/link_core/version.py index 65c02114..82e0470f 100644 --- a/mcp_package/link_core/version.py +++ b/mcp_package/link_core/version.py @@ -4,7 +4,7 @@ import re from pathlib import Path -LINK_VERSION = "1.7.0" +LINK_VERSION = "2.0.0" def workspace_runtime_version(root: Path) -> str | None: diff --git a/mcp_package/link_mcp/__init__.py b/mcp_package/link_mcp/__init__.py index b28df9c1..81371863 100644 --- a/mcp_package/link_mcp/__init__.py +++ b/mcp_package/link_mcp/__init__.py @@ -1,2 +1,2 @@ """Link MCP Server — personal knowledge wiki as MCP tools.""" -__version__ = "1.7.0" +__version__ = "2.0.0" diff --git a/mcp_package/pyproject.toml b/mcp_package/pyproject.toml index 8f1ce78e..f767b076 100644 --- a/mcp_package/pyproject.toml +++ b/mcp_package/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "link-mcp" -version = "1.7.0" +version = "2.0.0" description = "MCP server for Link local agent memory — remember, recall, search, context, and graph traversal" readme = "README.md" license = { text = "MIT" } diff --git a/mcp_package/server.json b/mcp_package/server.json index 9e55ff94..e214cf29 100644 --- a/mcp_package/server.json +++ b/mcp_package/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/gowtham0992/link", "source": "github" }, - "version": "1.7.0", + "version": "2.0.0", "packages": [ { "registryType": "pypi", "identifier": "link-mcp", - "version": "1.7.0", + "version": "2.0.0", "transport": { "type": "stdio" } diff --git a/packaging/RELEASE-linkbar.md b/packaging/RELEASE-linkbar.md new file mode 100644 index 00000000..000e4468 --- /dev/null +++ b/packaging/RELEASE-linkbar.md @@ -0,0 +1,43 @@ +# LinkBar release checklist (rides the Link 2.0.0 release) + +1. `python3 scripts/prepare_release.py 2.0.0` on develop -> commit -> push +2. PR develop -> main, wait for all 10 CI checks, merge (merge commit) +3. `git switch main && git pull --ff-only && git tag -a v2.0.0 -m v2.0.0 && git push origin v2.0.0` +4. PyPI + mcp-publisher (same as 1.7 runbook) +5. Formula bump in the tap (url/sha for v2.0.0 tarball) AND replace the + caveats block with the version below — it is the only place a + `brew install` user learns LinkBar exists, and the MCP paragraph is + obsolete (1.7 self-provisions ~/.link-mcp-venv): + + ```ruby + def caveats + <<~EOS + Try Link: + lnk proof # prove cross-agent memory in ~1 second + lnk try # the full demo wiki + + Wire your agent (creates ~/link, provisions MCP, writes hooks): + lnk onboard --agent claude-code --hooks --write + + Optional, macOS: put the review gate in your menu bar — + notifications when memory is captured, a global palette (Opt-Cmd-M), + and a live view of every Link surface: + brew install --cask gowtham0992/link/linkbar + + Optional: meaning-based recall (one-time local model download): + lnk semantic ~/link --setup + EOS + end + ``` +6. **LinkBar zip**: `cd apps/LinkBar && bash Scripts/bundle.sh --release-zip` + - attach `.build/LinkBar-1.0.0.zip` to the v2.0.0 GitHub release +7. **Cask**: copy `packaging/linkbar.rb` to the tap as `Casks/linkbar.rb`, + paste the sha256 from step 6, `git push` the tap +8. Verify as user #1: `brew install --cask gowtham0992/link/linkbar` + -> app opens with no Gatekeeper dialog; menu icon appears +9. Back-merge main -> develop +10. QA before step 1, on the installed app: + - press ⌥⌘M anywhere -> palette appears, recall works, `+text` remembers + - end an agent session with a memorable line -> notification banner with + Accept appears; Accept works from the banner + - Status tab all green diff --git a/packaging/linkbar.rb b/packaging/linkbar.rb new file mode 100644 index 00000000..50624671 --- /dev/null +++ b/packaging/linkbar.rb @@ -0,0 +1,27 @@ +# LinkBar cask — copy into gowtham0992/homebrew-link:Casks/linkbar.rb at +# release time, filling in the sha256 printed by `bundle.sh --release-zip`. +cask "linkbar" do + version "1.0.0" + sha256 "REPLACE_WITH_ZIP_SHA256" + + url "https://github.com/gowtham0992/link/releases/download/v2.0.0/LinkBar-#{version}.zip" + name "LinkBar" + desc "Link's agent memory, ambient in the menu bar" + homepage "https://github.com/gowtham0992/link" + + depends_on formula: "gowtham0992/link/link" + depends_on macos: ">= :sonoma" + + app "LinkBar.app" + + # LinkBar ships unsigned (open source, no Apple Developer certificate). + # Homebrew quarantines staged apps by default, which would block first + # launch of an unsigned bundle; stripping the flag here restores the + # normal double-click experience. Verified on macOS 15 and 26. + postflight do + system_command "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "#{appdir}/LinkBar.app"] + end + + zap trash: [] +end diff --git a/scripts/check_type_ratchet.py b/scripts/check_type_ratchet.py index 07240a29..b2454f60 100644 --- a/scripts/check_type_ratchet.py +++ b/scripts/check_type_ratchet.py @@ -26,7 +26,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -TYPE_ERROR_BASELINE = 387 +TYPE_ERROR_BASELINE = 385 def main() -> int: diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 2d7ed236..6249de55 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -27,6 +27,7 @@ class ReleaseFiles: core_version: Path server_json: Path changelog: Path + homepage: Path def release_files(root: Path = ROOT) -> ReleaseFiles: @@ -36,6 +37,7 @@ def release_files(root: Path = ROOT) -> ReleaseFiles: core_version=root / "mcp_package/link_core/version.py", server_json=root / "mcp_package/server.json", changelog=root / "CHANGELOG.md", + homepage=root / "docs/index.html", ) @@ -168,6 +170,21 @@ def update_changelog(text: str, version: str, release_date: str) -> str: return header + released + rest.lstrip() +HOMEPAGE_BANNER_RE = re.compile(r"(New \\u00b7 Link )\d+\.\d+\.\d+( is out)") +HOMEPAGE_TAG_RE = re.compile(r"(releases\\u002Ftag\\u002Fv)\d+\.\d+\.\d+") + + +def update_homepage(text: str, version: str) -> str: + """Point the site's release banner at the version being released. + + The banner advertises a download, so a stale number sends visitors to a + tag that does not exist yet — keep it in the release step, not in a + human's memory. + """ + text = HOMEPAGE_BANNER_RE.sub(rf"\g<1>{version}\g<2>", text) + return HOMEPAGE_TAG_RE.sub(rf"\g<1>{version}", text) + + def prepare_release(root: Path, version: str, release_date: str, dry_run: bool = False) -> list[Path]: files = release_files(root) version = normalize_version(version) @@ -183,6 +200,12 @@ def prepare_release(root: Path, version: str, release_date: str, dry_run: bool = files.server_json: update_server_json(files.server_json.read_text(encoding="utf-8"), version), files.changelog: update_changelog(files.changelog.read_text(encoding="utf-8"), version, release_date), } + # The homepage is presentation, not a package artifact: update it when + # present (the real repo), skip it in minimal trees/tests. + if files.homepage.exists(): + updates[files.homepage] = update_homepage( + files.homepage.read_text(encoding="utf-8"), version + ) changed = [path for path, text in updates.items() if path.read_text(encoding="utf-8") != text] if not dry_run: diff --git a/tests/test_semantic_core.py b/tests/test_semantic_core.py index 78b6dfd2..85159888 100644 --- a/tests/test_semantic_core.py +++ b/tests/test_semantic_core.py @@ -293,3 +293,14 @@ def test_reranker_offline_guard_returns_none_without_model(self): if __name__ == "__main__": unittest.main() + + +class SemanticStatusCountTests(unittest.TestCase): + def test_cli_semantic_counts_all_memories_not_only_active(self): + # The index embeds every memory (archived included), so the status + # denominator must be the total: "Indexed 16 of 6" was comparing + # all-status indexed items against the active-only count. + link_py = Path(__file__).resolve().parents[1] / "link.py" + src = link_py.read_text(encoding="utf-8") + self.assertIn("memory_count=total_count", src) + self.assertNotIn("memory_count=active_count", src)