From e1713bce7a5d8f8561d282554436a3b06b6624eb Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 11:53:57 -0600 Subject: [PATCH 01/29] LinkBar v0.4: the review gate in your menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reintroduction on a clean base (earlier branch history had swept-in build artifacts). SwiftUI MenuBarExtra over `lnk --json`: - Live kqueue watchers on the workspace — the badge and popover update the moment a session hook writes a capture; verified live. - One field, two verbs: Enter asks memory, ⌘↩ remembers (review-gated). - Capture rows show "Will save: " via capture-inbox previews; timestamps disambiguate same-titled sessions. - Recall always answers: results, honest abstention, or "no matches". - Secret refusals from the new remember gate surface as a friendly flash; stale-runtime warnings show a banner with one-click Fix. - Launch at login (SMAppService), settings sheet, reveal-in-Finder on every row, auto-fading toned flashes, ⌘R/⌘Q, version footer. - LINKBAR_SNAPSHOT=/path.png renders the popover to PNG through an offscreen AppKit window for screenshot verification. --- apps/LinkBar/Package.swift | 14 + apps/LinkBar/README.md | 25 ++ apps/LinkBar/Scripts/bundle.sh | 30 ++ apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 78 ++++ apps/LinkBar/Sources/LinkBar/LinkCLI.swift | 81 ++++ apps/LinkBar/Sources/LinkBar/LinkStore.swift | 329 ++++++++++++++++ apps/LinkBar/Sources/LinkBar/Models.swift | 127 ++++++ .../LinkBar/Sources/LinkBar/PopoverView.swift | 361 ++++++++++++++++++ .../Sources/LinkBar/Resources/AppIcon.icns | Bin 0 -> 110890 bytes .../Sources/LinkBar/Resources/MenuIcon.png | Bin 0 -> 726 bytes .../Sources/LinkBar/Resources/MenuIcon@2x.png | Bin 0 -> 1031 bytes 11 files changed, 1045 insertions(+) create mode 100644 apps/LinkBar/Package.swift create mode 100644 apps/LinkBar/README.md create mode 100755 apps/LinkBar/Scripts/bundle.sh create mode 100644 apps/LinkBar/Sources/LinkBar/LinkBarApp.swift create mode 100644 apps/LinkBar/Sources/LinkBar/LinkCLI.swift create mode 100644 apps/LinkBar/Sources/LinkBar/LinkStore.swift create mode 100644 apps/LinkBar/Sources/LinkBar/Models.swift create mode 100644 apps/LinkBar/Sources/LinkBar/PopoverView.swift create mode 100644 apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns create mode 100644 apps/LinkBar/Sources/LinkBar/Resources/MenuIcon.png create mode 100644 apps/LinkBar/Sources/LinkBar/Resources/MenuIcon@2x.png 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..f05df654 --- /dev/null +++ b/apps/LinkBar/Scripts/bundle.sh @@ -0,0 +1,30 @@ +#!/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 + CFBundleShortVersionString0.1.0 + LSMinimumSystemVersion14.0 + LSUIElement + CFBundleIconFileAppIcon + + +PLIST +codesign --force --sign - "$APP" 2>/dev/null || true +echo "bundled: $APP" diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift new file mode 100644 index 00000000..2bb616dc --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -0,0 +1,78 @@ +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() } + } 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)) + } + } + .onAppear { Self.snapshotIfRequested(store: store) } + } + .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. + @MainActor + static func snapshotIfRequested(store: LinkStore) { + guard let path = ProcessInfo.processInfo.environment["LINKBAR_SNAPSHOT"] else { return } + store.start() + Task { @MainActor in + // Real AppKit backing so SF Symbols and text fields render. + let host = NSHostingView( + rootView: 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..b83a89ca --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/LinkCLI.swift @@ -0,0 +1,81 @@ +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 + } + + /// 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..996390bf --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -0,0 +1,329 @@ +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 runtimeWarning: String? + @Published var launchAtLogin: Bool = SMAppService.mainApp.status == .enabled + + var pendingCount: Int { + (inbox?.reviewCount ?? 0) + (captures?.count ?? 0) + } + + private var timer: Timer? + private var watchers: [DirectoryWatcher] = [] + private var refreshDebounce: DispatchWorkItem? + private var flashGeneration = 0 + + func start() { + 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 func startWatching() { + let root = LinkCLI.workspace + let paths = [ + root, + (root as NSString).appendingPathComponent("raw/memory-captures"), + (root as NSString).appendingPathComponent("wiki/memories"), + (root as NSString).appendingPathComponent("wiki"), + ] + watchers = paths.compactMap { path in + DirectoryWatcher(path: path) { [weak self] in + Task { @MainActor in self?.scheduleRefresh() } + } + } + } + + /// 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", "4"]) + let status = try? LinkCLI.runJSON(StatusPayload.self, ["status", workspace, "--json"]) + await MainActor.run { + 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?.entries ?? self.activity + if let status { + self.linkVersion = status.version ?? self.linkVersion + self.runtimeWarning = status.warnings? + .first { $0.code == "stale_runtime" }? + .message + } + self.busy = false + } + } + } + + 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's first proposal into reviewed memory flow. + func acceptCapture(_ capture: CaptureItem) { + act(["accept-capture", capture.path, LinkCLI.workspace, "--index", "1"]) + } + + 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 + } + } + } + } + + 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) + } + } + + 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/")!) + request.timeoutInterval = 0.5 + request.httpMethod = "HEAD" + do { + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse) != nil + } 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..a69405e8 --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -0,0 +1,127 @@ +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? + + var id: String { path } + var displayTitle: String { + title ?? (path as NSString).lastPathComponent + } + + /// Capture filenames start with a UTC stamp (20260712T165329Z-…); + /// showing it disambiguates same-titled sessions. + var capturedAt: String? { + let name = (path as NSString).lastPathComponent + guard name.count >= 16, name.prefix(8).allSatisfy(\.isNumber) else { return nil } + let month = Int(name.dropFirst(4).prefix(2)) ?? 0 + let day = name.dropFirst(6).prefix(2) + let hour = name.dropFirst(9).prefix(2) + let minute = name.dropFirst(11).prefix(2) + let months = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + guard (1...12).contains(month) else { return nil } + return "\(day) \(months[month]) \(hour):\(minute) UTC" + } +} + +struct MemoryLog: Decodable { + let entries: [LogEntry] +} + +struct LogEntry: Decodable, Identifiable { + let timestamp: String + let operation: String + let description: String? + + var id: String { timestamp + operation } +} + +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]? +} + +struct RememberResult: Decodable { + let created: Bool + let secret: Bool? + let message: String? +} diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift new file mode 100644 index 00000000..ea0caa6b --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -0,0 +1,361 @@ +import SwiftUI + +struct PopoverView: View { + @EnvironmentObject var store: LinkStore + @State private var query = "" + @State private var showSettings = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + if let warning = store.runtimeWarning { + runtimeBanner(warning) + } + recallField + if store.searchedQuery != nil || !store.recallResults.isEmpty { + recallSection + Divider() + } + inboxSection + if let captures = store.captures, !captures.captures.isEmpty { + Divider() + capturesSection(captures) + } + if !store.activity.isEmpty { + Divider() + activitySection + } + footer + } + .padding(12) + .frame(width: 380) + .sheet(isPresented: $showSettings) { + SettingsSheet() + .environmentObject(store) + } + } + + private var header: some View { + HStack { + Text("Link").font(.headline) + Spacer() + if store.busy { ProgressView().controlSize(.small) } + Button { + store.rememberClipboard() + } label: { Image(systemName: "doc.on.clipboard") } + .buttonStyle(.borderless) + .help("Remember clipboard — saved as pending review") + Button { + store.openDashboard() + } label: { Image(systemName: "gauge.with.needle") } + .buttonStyle(.borderless) + .help("Open the Memory Dashboard (starts the local viewer if needed)") + Button { + store.openWorkspace() + } label: { Image(systemName: "folder") } + .buttonStyle(.borderless) + .help("Open the workspace in Finder") + Button { + store.refresh() + } label: { Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .keyboardShortcut("r", modifiers: .command) + .help("Refresh (⌘R)") + Button { + showSettings = true + } label: { Image(systemName: "gearshape") } + .buttonStyle(.borderless) + .help("Settings") + } + } + + /// 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) + Text(warning) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button("Fix") { store.repairRuntime() } + .controlSize(.small) + .help("Refresh the workspace runtime (lnk init)") + } + .padding(8) + .background(.yellow.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private var recallField: some View { + HStack(spacing: 6) { + TextField("Ask your memory — or type something to keep…", text: $query) + .textFieldStyle(.roundedBorder) + .onSubmit { store.recall(query) } + Button { + store.rememberText(query) + query = "" + } label: { Image(systemName: "plus.circle") } + .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: 6) { + if store.recallResults.isEmpty, store.abstention?.recommended != true, + let searched = store.searchedQuery { + Label( + "No matches for \"\(searched)\" — try different words.", + systemImage: "magnifyingglass" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + if let abstention = store.abstention, abstention.recommended { + Label( + "Nothing reliable on this — the honest answer is \"don't know\".", + systemImage: "questionmark.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(store.recallResults.prefix(4)) { memory in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(memory.title).font(.callout).lineLimit(1) + Spacer() + if let confidence = memory.confidence { + Text(confidence) + .font(.caption2) + .padding(.horizontal, 5) + .background(confidenceColor(confidence).opacity(0.2)) + .clipShape(Capsule()) + } + } + if let tldr = memory.tldr { + Text(tldr).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + .contentShape(Rectangle()) + .contextMenu { + Button("Reveal in Finder") { store.revealMemory(named: memory.name) } + } + .onTapGesture(count: 2) { store.revealMemory(named: memory.name) } + .help("Double-click or right-click to open the memory file") + } + } + } + + private var inboxSection: some View { + VStack(alignment: .leading, spacing: 6) { + let items = store.inbox?.items ?? [] + HStack { + Text("Review inbox").font(.subheadline).bold() + Spacer() + Text("\(store.inbox?.reviewCount ?? 0)") + .font(.caption) + .foregroundStyle(.secondary) + } + if items.isEmpty { + Label("Nothing waiting — your memory is fully reviewed.", systemImage: "checkmark.seal") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(items.prefix(5)) { item in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.callout).lineLimit(1) + HStack(spacing: 6) { + Text(item.memoryType).font(.caption2).foregroundStyle(.secondary) + 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() + Button("✓") { store.markReviewed(item) } + .buttonStyle(.borderedProminent) + .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") + } + } + } + } + + private func capturesSection(_ captures: CaptureInbox) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Session captures").font(.subheadline).bold() + Spacer() + Text("\(captures.count)").font(.caption).foregroundStyle(.secondary) + } + ForEach(captures.captures.prefix(3)) { capture in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(capture.displayTitle).font(.callout).lineLimit(1) + HStack(spacing: 6) { + if let stamp = capture.capturedAt { + Text(stamp).font(.caption2).foregroundStyle(.secondary) + } + if let project = capture.project, !project.isEmpty { + Text(project).font(.caption2).foregroundStyle(.tertiary) + } + } + if let first = capture.proposals?.first, let memory = first.memory { + Text("Will save: \(memory)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if let snippet = capture.snippet, !snippet.isEmpty { + Text(snippet) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } + .contentShape(Rectangle()) + .contextMenu { + Button("Reveal in Finder") { store.revealCapture(capture) } + } + Spacer() + Button("Accept") { store.acceptCapture(capture) } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .help("Accept the first proposal into reviewed memory") + Button { + store.deleteCapture(capture) + } label: { Image(systemName: "trash") } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Discard this capture") + } + } + } + } + + private var activitySection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Recent activity").font(.subheadline).bold() + ForEach(store.activity.prefix(3)) { entry in + HStack(spacing: 6) { + Text(entry.operation) + .font(.caption2) + .padding(.horizontal, 5) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + Text(entry.description ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 6) { + if let flash = store.flash { + Text(flash) + .font(.caption2) + .foregroundStyle(store.flashTone == .success ? Color.green : Color.secondary) + .lineLimit(1) + .transition(.opacity) + } + if let error = store.lastError { + Text(error).font(.caption2).foregroundStyle(.red).lineLimit(2) + } + Divider() + HStack { + Text(LinkCLI.workspace) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.head) + Spacer() + Text("LinkBar 0.4" + (store.linkVersion.isEmpty ? "" : " · Link \(store.linkVersion)")) + .font(.caption2) + .foregroundStyle(.tertiary) + Button("Quit") { NSApplication.shared.terminate(nil) } + .buttonStyle(.borderless) + .font(.caption) + .keyboardShortcut("q", modifiers: .command) + } + } + } + + private func confidenceColor(_ value: String) -> Color { + switch value { + case "strong": return .green + case "moderate": return .orange + default: return .gray + } + } +} + +struct SettingsSheet: View { + @EnvironmentObject var store: LinkStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("LinkBar Settings").font(.headline) + + 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) + } + } + + 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("Updates").font(.subheadline) + Text("LinkBar refreshes the moment your workspace changes — session hooks, agents, and edits all show up live.") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + if !store.linkVersion.isEmpty { + Text("Link \(store.linkVersion)") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .frame(width: 340) + } +} diff --git a/apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns b/apps/LinkBar/Sources/LinkBar/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..c04e7d22a4a33ddb3675c3140855ae7a3e597cab GIT binary patch literal 110890 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 A^8f$< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5e0a200c2a87c8eac13f42d4a4cce31f7a2f7660 GIT binary patch literal 1031 zcmV+i1o-=jP)4Tx04R}tkWEWMQ4oe7%aBUYprSUa2byq(g$e&7FVeYt$qmiQ)a*vfIi7ZW!3U8VU@ zc+iodgpV~BT70HC+v1C6&T1wld?OsTi&jqfQ8=3I&7IJmKX=&H z)Ho!T<*AXjmOX;=CkPF-wTUtL&NWh_G$;{F=+oOs^LIs_)6aqoGr zHP36DLiYn)*53gN`&%LEm+W5v001~;SV?A0O#mtY000O80f%V-1ONa40RR918UO$Q z0007@0ssU600031001PQ0{{d700031001Na00009#=*t_00J{fL_t(o3GJ6XYg9oL zhT|t9Dh5eqr(oj;A_zhtO#=P_wNWg!6MO%Ipdwg_))v}{1Pck6B2B;)f@o!vE+i`A z_w(pI>*2Dyxtn|UMr2=jcHTX6?wr|~VRv?HY^48m1upQpUcg#`-dO9}LdE;=vj_H> z)0(bRDJJs+|FS?IadTSHR;nz6g}=Zwj2GyQ*=LTqL)TF%#46mwABT?xdSlUVZX?>> zXOz9E?>&ABb{8?dvCy6{b=!VR{g%N!E9x9<`|bX`=#43J%xy(asS?}rx`#KXH65YS z3dE$!t$pUSsuNUu1?9;Yvu{)6S-S->DSf~5x8X@v9n^#SCRNeLfluN3w(5kebUKyG zK84$}mnaq66|J#N(>#@9BGDRaT!U;`>}u5SmK zQ2lBZM(B6_C_la<1+q`!b*&3<3ig9PJznlMq$@o23E~Xtjg{s7`T%rW`AOnlQ(}y7 z_`9$Z2GV)5V~}!dU+4_RpA>Vvyq$&6AZr;;h9$ClT~RGE3vRs=*aab1 z;2C^|lW-Wi={v!LP-*)-E8RX-)3KSDX}7(OKHIPGAqR%!z35fe12TK9$P@aG6+ABG z&85B@rM^6#2YtPTA?2?ou=ze6gBi0g3K002ovPDHLkV1kVT B+TH*F literal 0 HcmV?d00001 From 345b1f0136aca863bd25bbc00a7ef5367c45da05 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 12:16:37 -0600 Subject: [PATCH 02/29] LinkBar v0.5: the design pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction: a native macOS inbox with Link's editorial soul — system materials and SF Pro do the structure, rust does the opinion, one serif wordmark is the signature. - DesignSystem.swift: LinkBrand tokens (rust with a lifted dark-mode variant, 4/8 spacing rhythm), SectionHeader (tracked uppercase labels with count badges — rust when work is pending), HoverRow, and the serif Wordmark. - Rust replaces default-blue everywhere an action lives: Accept, review checkmark, the remember ⊕, settings toggle, Fix button. - Real hierarchy: titles at 12.5 medium, metadata in tertiary, memory type in rust, timestamps inline; search field with an embedded magnifier; two-tone "Will save:" previews. - Designed idle state (rust seal, "All reviewed.") instead of empty sections; flash is a toned pill that slides in and fades. - LINKBAR_APPEARANCE=light|dark forces the snapshot appearance so both modes are verified from the command line. --- .../Sources/LinkBar/DesignSystem.swift | 75 +++ apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 5 + .../LinkBar/Sources/LinkBar/PopoverView.swift | 434 +++++++++++------- 3 files changed, 343 insertions(+), 171 deletions(-) create mode 100644 apps/LinkBar/Sources/LinkBar/DesignSystem.swift diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift new file mode 100644 index 00000000..83b52461 --- /dev/null +++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift @@ -0,0 +1,75 @@ +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 { + /// 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)) + } + } +} diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift index 2bb616dc..4de7c84b 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -39,6 +39,11 @@ struct LinkBarApp: App { @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() Task { @MainActor in // Real AppKit backing so SF Symbols and text fields render. diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index ea0caa6b..eb0ce695 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -5,8 +5,15 @@ struct PopoverView: View { @State private var query = "" @State private var showSettings = false + 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: 10) { + VStack(alignment: .leading, spacing: LinkBrand.betweenSections) { header if let warning = store.runtimeWarning { runtimeBanner(warning) @@ -14,59 +21,67 @@ struct PopoverView: View { recallField if store.searchedQuery != nil || !store.recallResults.isEmpty { recallSection - Divider() } - inboxSection - if let captures = store.captures, !captures.captures.isEmpty { - Divider() - capturesSection(captures) + if isFullyIdle { + idleState + } else { + inboxSection + if let captures = store.captures, !captures.captures.isEmpty { + capturesSection(captures) + } } - if !store.activity.isEmpty { - Divider() + if !store.activity.isEmpty && !isFullyIdle { activitySection } footer } - .padding(12) + .padding(LinkBrand.pad) .frame(width: 380) + .animation(.easeOut(duration: 0.18), value: store.pendingCount) .sheet(isPresented: $showSettings) { SettingsSheet() .environmentObject(store) } } + // MARK: header + private var header: some View { - HStack { - Text("Link").font(.headline) + HStack(spacing: 2) { + Wordmark() Spacer() - if store.busy { ProgressView().controlSize(.small) } - Button { + if store.busy { + ProgressView().controlSize(.small).padding(.trailing, 4) + } + toolbarButton("doc.on.clipboard", help: "Remember clipboard — saved as pending review") { store.rememberClipboard() - } label: { Image(systemName: "doc.on.clipboard") } - .buttonStyle(.borderless) - .help("Remember clipboard — saved as pending review") - Button { + } + toolbarButton("gauge.with.needle", help: "Open the Memory Dashboard") { store.openDashboard() - } label: { Image(systemName: "gauge.with.needle") } - .buttonStyle(.borderless) - .help("Open the Memory Dashboard (starts the local viewer if needed)") - Button { + } + toolbarButton("folder", help: "Open the workspace in Finder") { store.openWorkspace() - } label: { Image(systemName: "folder") } - .buttonStyle(.borderless) - .help("Open the workspace in Finder") - Button { + } + toolbarButton("arrow.clockwise", help: "Refresh (⌘R)") { store.refresh() - } label: { Image(systemName: "arrow.clockwise") } - .buttonStyle(.borderless) - .keyboardShortcut("r", modifiers: .command) - .help("Refresh (⌘R)") - Button { + } + .keyboardShortcut("r", modifiers: .command) + toolbarButton("gearshape", help: "Settings") { showSettings = true - } label: { Image(systemName: "gearshape") } - .buttonStyle(.borderless) - .help("Settings") + } + } + } + + 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. @@ -74,206 +89,274 @@ struct PopoverView: 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() + Spacer(minLength: 4) Button("Fix") { store.repairRuntime() } .controlSize(.small) + .tint(LinkBrand.rust) .help("Refresh the workspace runtime (lnk init)") } - .padding(8) - .background(.yellow.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(10) + .background(.yellow.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) } + // MARK: ask / remember + private var recallField: some View { HStack(spacing: 6) { - TextField("Ask your memory — or type something to keep…", text: $query) - .textFieldStyle(.roundedBorder) - .onSubmit { store.recall(query) } + 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) } + } + .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") } - .buttonStyle(.borderless) - .keyboardShortcut(.return, modifiers: .command) - .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty) - .help("Remember this text (⌘↩) — saved as pending review") + } 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: 6) { + 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 \"\(searched)\" — try different words.", - systemImage: "magnifyingglass" - ) - .font(.caption) - .foregroundStyle(.secondary) + 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 \"don't know\".", - systemImage: "questionmark.circle" - ) - .font(.caption) - .foregroundStyle(.secondary) + 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 - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(memory.title).font(.callout).lineLimit(1) - Spacer() - if let confidence = memory.confidence { - Text(confidence) - .font(.caption2) - .padding(.horizontal, 5) - .background(confidenceColor(confidence).opacity(0.2)) - .clipShape(Capsule()) + 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) } } - if let tldr = memory.tldr { - Text(tldr).font(.caption).foregroundStyle(.secondary).lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .contextMenu { + Button("Reveal in Finder") { store.revealMemory(named: memory.name) } } + .onTapGesture(count: 2) { store.revealMemory(named: memory.name) } } - .contentShape(Rectangle()) - .contextMenu { - Button("Reveal in Finder") { store.revealMemory(named: memory.name) } - } - .onTapGesture(count: 2) { store.revealMemory(named: memory.name) } .help("Double-click or right-click to open the memory file") } } } + 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: 6) { + VStack(alignment: .leading, spacing: LinkBrand.inGroup) { let items = store.inbox?.items ?? [] - HStack { - Text("Review inbox").font(.subheadline).bold() - Spacer() - Text("\(store.inbox?.reviewCount ?? 0)") - .font(.caption) - .foregroundStyle(.secondary) - } + 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 - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(item.title).font(.callout).lineLimit(1) - HStack(spacing: 6) { - Text(item.memoryType).font(.caption2).foregroundStyle(.secondary) - if let tldr = item.tldr { - Text(tldr).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + 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") } - .contentShape(Rectangle()) - .contextMenu { - Button("Reveal in Finder") { store.revealMemory(named: item.name) } - } - Spacer() - Button("✓") { store.markReviewed(item) } - .buttonStyle(.borderedProminent) - .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: 6) { - HStack { - Text("Session captures").font(.subheadline).bold() - Spacer() - Text("\(captures.count)").font(.caption).foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: LinkBrand.inGroup) { + SectionHeader(title: "Session captures", count: captures.count, highlight: captures.count > 0) ForEach(captures.captures.prefix(3)) { capture in - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(capture.displayTitle).font(.callout).lineLimit(1) - HStack(spacing: 6) { - if let stamp = capture.capturedAt { - Text(stamp).font(.caption2).foregroundStyle(.secondary) + 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 project = capture.project, !project.isEmpty { - Text(project).font(.caption2).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 first = capture.proposals?.first, let memory = first.memory { - Text("Will save: \(memory)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } else if let snippet = capture.snippet, !snippet.isEmpty { - Text(snippet) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(2) + .contentShape(Rectangle()) + .contextMenu { + Button("Reveal in Finder") { store.revealCapture(capture) } } + Spacer(minLength: 6) + Button("Accept") { store.acceptCapture(capture) } + .buttonStyle(.borderedProminent) + .tint(LinkBrand.rust) + .controlSize(.small) + .help("Accept the first proposal into reviewed memory") + Button { + store.deleteCapture(capture) + } label: { Image(systemName: "trash") } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Discard this capture") } - .contentShape(Rectangle()) - .contextMenu { - Button("Reveal in Finder") { store.revealCapture(capture) } - } - Spacer() - Button("Accept") { store.acceptCapture(capture) } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .help("Accept the first 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: 4) { - Text("Recent activity").font(.subheadline).bold() - ForEach(store.activity.prefix(3)) { entry in - HStack(spacing: 6) { - Text(entry.operation) - .font(.caption2) - .padding(.horizontal, 5) - .background(Color.secondary.opacity(0.15)) - .clipShape(Capsule()) - Text(entry.description ?? "") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + 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: say so nicely. + private var idleState: some View { + VStack(spacing: 6) { + Image(systemName: "checkmark.seal") + .font(.system(size: 22, weight: .light)) + .foregroundStyle(LinkBrand.rust.opacity(0.8)) + Text("All reviewed.") + .font(.system(size: 12.5, weight: .medium)) + Text("Your agents remember you — say something worth keeping.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + private var footer: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { if let flash = store.flash { - Text(flash) - .font(.caption2) - .foregroundStyle(store.flashTone == .success ? Color.green : Color.secondary) - .lineLimit(1) - .transition(.opacity) + 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) @@ -281,20 +364,22 @@ struct PopoverView: View { Divider() HStack { Text(LinkCLI.workspace) - .font(.caption2) - .foregroundStyle(.tertiary) + .font(.system(size: 10.5)) + .foregroundStyle(.quaternary) .lineLimit(1) .truncationMode(.head) Spacer() - Text("LinkBar 0.4" + (store.linkVersion.isEmpty ? "" : " · Link \(store.linkVersion)")) - .font(.caption2) + Text("LinkBar 0.5" + (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 { @@ -312,7 +397,12 @@ struct SettingsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("LinkBar Settings").font(.headline) + HStack(spacing: 7) { + Wordmark() + Text("Settings") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + } Toggle(isOn: Binding( get: { store.launchAtLogin }, @@ -325,6 +415,7 @@ struct SettingsSheet: View { .foregroundStyle(.secondary) } } + .tint(LinkBrand.rust) VStack(alignment: .leading, spacing: 2) { Text("Workspace").font(.subheadline) @@ -338,21 +429,22 @@ struct SettingsSheet: View { } VStack(alignment: .leading, spacing: 2) { - Text("Updates").font(.subheadline) - Text("LinkBar refreshes the moment your workspace changes — session hooks, agents, and edits all show up live.") + 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("Link \(store.linkVersion)") + Text("LinkBar 0.5 · Link \(store.linkVersion)") .font(.caption) .foregroundStyle(.tertiary) } Spacer() Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) + .tint(LinkBrand.rust) } } .padding(16) From c4e59da6b11978c0af19f7268967974b06cbfa64 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 12:22:54 -0600 Subject: [PATCH 03/29] bundle.sh --install replaces /Applications/LinkBar.app and relaunches --- apps/LinkBar/Scripts/bundle.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/LinkBar/Scripts/bundle.sh b/apps/LinkBar/Scripts/bundle.sh index f05df654..c7a2feea 100755 --- a/apps/LinkBar/Scripts/bundle.sh +++ b/apps/LinkBar/Scripts/bundle.sh @@ -28,3 +28,13 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' PLIST codesign --force --sign - "$APP" 2>/dev/null || true echo "bundled: $APP" + +# --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 From 8d6ec0031c019ed6d36b610d6767b0b5f7a64737 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 12:25:16 -0600 Subject: [PATCH 04/29] =?UTF-8?q?LinkBar:=20settings=20as=20an=20in-popove?= =?UTF-8?q?r=20pane=20=E2=80=94=20sheets=20misbehave=20in=20MenuBarExtra?= =?UTF-8?q?=20windows=20(Done=20dismissed=20the=20popover,=20state=20stuck?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LinkBar/Sources/LinkBar/PopoverView.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index eb0ce695..8484b4f5 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -13,6 +13,18 @@ struct PopoverView: View { } var body: some View { + Group { + if showSettings { + SettingsPane(done: { showSettings = false }) + .environmentObject(store) + } else { + mainContent + } + } + .frame(width: 380) + } + + private var mainContent: some View { VStack(alignment: .leading, spacing: LinkBrand.betweenSections) { header if let warning = store.runtimeWarning { @@ -36,12 +48,7 @@ struct PopoverView: View { footer } .padding(LinkBrand.pad) - .frame(width: 380) .animation(.easeOut(duration: 0.18), value: store.pendingCount) - .sheet(isPresented: $showSettings) { - SettingsSheet() - .environmentObject(store) - } } // MARK: header @@ -391,9 +398,9 @@ struct PopoverView: View { } } -struct SettingsSheet: View { +struct SettingsPane: View { @EnvironmentObject var store: LinkStore - @Environment(\.dismiss) private var dismiss + let done: () -> Void var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -442,12 +449,11 @@ struct SettingsSheet: View { .foregroundStyle(.tertiary) } Spacer() - Button("Done") { dismiss() } + Button("Done") { done() } .keyboardShortcut(.defaultAction) .tint(LinkBrand.rust) } } .padding(16) - .frame(width: 340) } } From d876eb9bebc7e8cd0f9cc0f796a464469a74a839 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 12:53:51 -0600 Subject: [PATCH 05/29] Capture provenance: reviewable, correctly-attributed session context Research into how the hook grabs session context surfaced three misalignments, now fixed: - Standing rules survived: extract_transcript_text gains keep_head, and the hook mines a head+tail window (9k) instead of recency-only 6k. A rule stated at the start of a long session no longer falls off the front. Verified live on an 82-turn session. - Correct attribution at accept time: the capture records a ## Proposal Source block (the user's own turns); accept-capture and the inbox preview mine from it, so the assistant's prose beneath the notes is never re-proposed as the user's preference. Closes a regression the earlier hook-time fix didn't cover. - The pipeline is reviewable: session-end persists a decision trail (## How Link Read This Session), surfaced on the dashboard Captures page (proposals + collapsible trail) and in capture-inbox --json with a mined_from_user_turns flag. --- CHANGELOG.md | 1 + link.py | 20 ++++++-- mcp_package/link_core/agent_hooks.py | 55 +++++++++++++++++--- mcp_package/link_core/capture.py | 77 +++++++++++++++++++++++++--- mcp_package/link_core/web_memory.py | 32 ++++++++++++ tests/test_agent_hooks_core.py | 15 ++++++ tests/test_capture_core.py | 47 +++++++++++++++++ tests/test_link_cli.py | 2 +- 8 files changed, 231 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cc901d..9f7ddac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Secrets are refused at the memory gate: `remember` (CLI and both MCP tools) now detects credential-shaped text — API-token patterns plus a conservative password heuristic ("Zk9#mango42", "the wifi password is …", "PIN 1234") — and refuses with a pointer to a password manager; `--allow-secret` overrides when the text truly isn't one. Memory pages are plain files injected into every connected agent's session, so a saved credential leaks by design. - Forgetting now forgets the log too: `forget-memory` scrubs the memory's title and name from past `wiki/log.md` entries, replacing them with `[forgotten memory]`. The log's tamper-evident hash chain is re-anchored and the redaction declares itself as a `redact-log` entry — never silent — and integrity verification passes afterwards. - The capture inbox shows what Accept will actually save: each capture in `capture-inbox --json` (and the text listing) now carries mined proposal previews (title, memory text, type, confidence) — the same deterministic miner accept-capture uses, with secret-looking values redacted from previews. +- Session captures are now reviewable and correctly attributed. Three fixes to how Link reads a session: (1) standing rules survive long sessions — the hook mines a head+tail window instead of only the most recent ~6k characters, so "from now on I only deploy through the release script" said early in a two-hour session is still captured; (2) accept-time re-mining reads the user's own turns only (recorded in a `## Proposal Source` block), closing a path where the assistant's prose could be re-proposed as your preference; (3) every capture records a decision trail (`## How Link Read This Session`) — messages kept, echoes dropped, proposals found — surfaced on the dashboard Captures page and in `capture-inbox --json` alongside the proposal previews and a "mined from your own turns" attribution flag. - Session captures now carry retrieval context end to end: when the session-end hook proposes a memory, the neighboring sentences around the claim's origin travel with the proposal, through accept-capture, into the memory page's `context` frontmatter — the same ±1-neighbor window that lifted LoCoMo hit@10 0.685→0.737 in the retrieval benchmark. Context helps recall *find* the memory (scoring + embeddings) but is never part of the claim: echo/duplicate/conflict checks, slim output, and the visible page body all exclude it. Also exposed explicitly as `lnk remember --context` and the `context` parameter on both MCP remember tools; capped at 600 characters. - The semantic and rerank tiers are now reachable from a Homebrew install: the Homebrew Python refuses direct pip installs (PEP 668), so every printed `pip install "link-mcp[semantic]"` command failed on a fresh Mac. `lnk semantic --setup` now detects an externally-managed runtime, provisions the extras into `~/.link-mcp-venv` (pinned to Link's version), and reruns the setup under that Python — one command from lexical-only to hybrid + rerank. Status and verify-mcp guidance stop printing pip commands the interpreter would refuse. The Homebrew `lnk` shim (tap revision 1) runs through the managed venv whenever it hosts link-mcp, so the CLI gains the tiers too. diff --git a/link.py b/link.py index cd6b9935..2ad318d2 100644 --- a/link.py +++ b/link.py @@ -1303,6 +1303,7 @@ def session_end( limit: int = 3, project: str | None = None, proposal_text: str | None = None, + decision_trail: list[str] | None = None, json_output: bool = False, ) -> int: target = target.expanduser().resolve() @@ -1325,6 +1326,8 @@ def session_end( project=project_name, default_source="session-end", path_source=True, + proposal_text=proposal_text, + decision_trail=decision_trail, ) rel_path = str(capture_record["path"]) # The raw capture keeps the full session for review context, but memory @@ -2266,7 +2269,10 @@ def _hook_session_end( target: Path, hook_event: dict[str, object], limit: int, project: str | None, explain: bool = False, ) -> int: + trail: list[str] = [] + def _trace(message: str) -> None: + trail.append(message) if explain: print(f"[session-end] {message}") @@ -2278,8 +2284,8 @@ def _trace(message: str) -> None: extraction_stats: dict[str, int] = {} notes = _core_extract_transcript_text(transcript_path, stats=extraction_stats) _trace( - f"transcript: kept {extraction_stats.get('kept_messages', 0)} messages, " - f"dropped {extraction_stats.get('dropped_link_output', 0)} carrying Link's own output (echo guard, layer 1)." + f"Read the session: kept {extraction_stats.get('kept_messages', 0)} messages, " + f"dropped {extraction_stats.get('dropped_link_output', 0)} carrying Link's own injected output (echo guard, layer 1)." ) if len(notes.strip()) < 200: _trace("skipped: under 200 characters of conversation — nothing memory-worthy in a trivial session.") @@ -2288,7 +2294,12 @@ def _trace(message: str) -> None: # prose is help, not the user's preferences; mining it would attribute the # assistant's words to the user (found in dogfooding). The raw capture below # still keeps the full transcript for review context. - user_notes = _core_extract_transcript_text(transcript_path, roles=("user",)) + # Mine from a head+tail window so an opening standing rule ("from now on…") + # survives even in a long session, where a recency-only window would drop it. + user_notes = _core_extract_transcript_text( + transcript_path, roles=("user",), max_chars=9000, keep_head=True + ) + _trace("Mined memory only from your own turns — the assistant's prose is never proposed as your preference.") # Skip duplicate firings for the same conversation content (e.g. /clear # immediately followed by exit, or repeated end events). state_path = _session_end_hook_state_path(target) @@ -2340,7 +2351,7 @@ def _trace(message: str) -> None: if not fresh: _trace("no fresh proposals left; no capture stored.") return 0 - _trace(f"storing a proposal-only capture with {len(fresh)} fresh proposal(s) for review.") + _trace(f"Stored a proposal-only capture with {len(fresh)} fresh proposal(s) for your review.") code = session_end( target, notes, @@ -2348,6 +2359,7 @@ def _trace(message: str) -> None: limit=proposal_limit, project=project_name, proposal_text=user_notes, + decision_trail=trail, ) if code == 0: try: diff --git a/mcp_package/link_core/agent_hooks.py b/mcp_package/link_core/agent_hooks.py index 2e1e4bb0..d939cd34 100644 --- a/mcp_package/link_core/agent_hooks.py +++ b/mcp_package/link_core/agent_hooks.py @@ -349,6 +349,7 @@ def extract_transcript_text( max_message_chars: int = 800, roles: tuple[str, ...] = ("user", "assistant"), stats: dict[str, int] | None = None, + keep_head: bool = False, ) -> str: """Extract bounded conversation text from an agent transcript JSONL file. @@ -359,6 +360,10 @@ def extract_transcript_text( user said — memory proposals should come from the user's own words, not the assistant's prose, which would otherwise be mis-attributed as user preferences. + + With `keep_head`, when the turns exceed the budget Link keeps the opening + turns as well as the recent ones. Standing rules ("from now on…") are + stated early in a session; a recency-only window silently drops them. """ role_set = set(roles) try: @@ -396,12 +401,48 @@ def extract_transcript_text( lines.append(f"{role}: {text}") if not lines: return "" - kept: list[str] = [] - total = 0 - for line in reversed(lines): + + def _fits(selected: list[str]) -> bool: + return sum(len(item) + 2 for item in selected) <= max_chars + + if _fits(lines): + return "\n\n".join(lines) + + if not keep_head: + kept: list[str] = [] + total = 0 + for line in reversed(lines): + cost = len(line) + 2 + if kept and total + cost > max_chars: + break + kept.append(line) + total += cost + return "\n\n".join(reversed(kept)) + + # Head + tail: opening turns carry standing rules, recent turns carry the + # session's decisions. Spend ~a third of the budget on the head. + head_budget = max_chars // 3 + head: list[str] = [] + head_total = 0 + consumed = 0 + for index, line in enumerate(lines): + cost = len(line) + 2 + if head and head_total + cost > head_budget: + break + head.append(line) + head_total += cost + consumed = index + 1 + + tail: list[str] = [] + tail_total = 0 + for line in reversed(lines[consumed:]): cost = len(line) + 2 - if kept and total + cost > max_chars: + if tail and head_total + tail_total + cost > max_chars: break - kept.append(line) - total += cost - return "\n\n".join(reversed(kept)) + tail.append(line) + tail_total += cost + tail.reverse() + + if head and tail: + return "\n\n".join(head) + "\n\n… (middle of the session omitted) …\n\n" + "\n\n".join(tail) + return "\n\n".join(head or tail) diff --git a/mcp_package/link_core/capture.py b/mcp_package/link_core/capture.py index d3103456..08239226 100644 --- a/mcp_package/link_core/capture.py +++ b/mcp_package/link_core/capture.py @@ -76,8 +76,16 @@ def write_session_capture( timestamp: str | None = None, default_source: str = "inline", path_source: bool = False, + proposal_text: str | None = None, + decision_trail: list[str] | None = None, ) -> dict[str, object]: - """Persist proposal-only session notes under raw/memory-captures.""" + """Persist proposal-only session notes under raw/memory-captures. + + `proposal_text` records exactly which turns memory is mined from (the + user's own turns), so accept-time re-mining reads the same words the + hook did — never the assistant's prose. `decision_trail` records why + proposals were kept or dropped, so the pipeline is reviewable. + """ root = root.expanduser().resolve() notes = text.strip() if not notes: @@ -97,6 +105,23 @@ def write_session_capture( capture_dir.mkdir(parents=True, exist_ok=True) capture_path = capture_filename(captured_at, capture_name, capture_dir) project_line = f'project: "{frontmatter_string(project_name)}"\n' if project_name else "" + + # Proposal source: the user's own turns, redacted, so accept-time mining + # reads exactly what the hook mined — not the assistant's prose beneath. + mined = (proposal_text or "").strip() + mined_section = "" + if mined and mined != notes: + safe_mined, _, _ = redact_secret_values(mined) + mined_section = f"\n## Proposal Source\n\nMemory is mined only from these (the user's own turns):\n\n{safe_mined}\n" + + trail_section = "" + if decision_trail: + safe_lines = [] + for line in decision_trail: + safe, _, _ = redact_secret_values(str(line)) + safe_lines.append(f"- {safe}") + trail_section = "\n## How Link Read This Session\n\n" + "\n".join(safe_lines) + "\n" + atomic_write_text( capture_path, f"""--- @@ -112,7 +137,7 @@ def write_session_capture( ## Source Input {source_value} - +{trail_section}{mined_section} ## Notes {notes} @@ -170,6 +195,38 @@ def capture_notes_from_markdown(text: str) -> tuple[dict[str, object], str]: return meta, notes +def capture_proposal_source(text: str) -> str | None: + """Return the `## Proposal Source` body (user turns) when present. + + Accept-time mining reads this instead of the full notes so the + assistant's prose is never re-attributed as the user's preference. + """ + _, body = parse_frontmatter(text) + match = re.search(r"^## Proposal Source\s*(.*?)(?=^## |\Z)", body, flags=re.MULTILINE | re.DOTALL) + if not match: + return None + section = match.group(1).strip() + # Drop the leading explanatory sentence Link writes above the turns. + section = re.sub( + r"^Memory is mined only from these \(the user's own turns\):\s*", + "", section, + ).strip() + return section or None + + +def capture_decision_trail(text: str) -> list[str]: + """Return the `## How Link Read This Session` bullet lines when present.""" + _, body = parse_frontmatter(text) + match = re.search(r"^## How Link Read This Session\s*(.*?)(?=^## |\Z)", body, flags=re.MULTILINE | re.DOTALL) + if not match: + return [] + return [ + line.lstrip("- ").strip() + for line in match.group(1).strip().splitlines() + if line.strip().startswith("-") + ] + + def capture_proposal_selection( root: Path, capture: str, @@ -200,8 +257,12 @@ def capture_proposal_selection( rel_path = capture_path.relative_to(root).as_posix() project_name = normalize_project(project or str(meta.get("project") or "") or default_project) + # Mine from the user's own turns when the capture recorded them, so accept + # never re-attributes the assistant's prose. Fall back to full notes for + # older captures written before proposal-source provenance existed. + mining_text = capture_proposal_source(raw_text) or notes proposals = propose_memories( - notes, + mining_text, rel_path, max(1, min(max(proposal_index, 10), 50)), project_name, @@ -381,9 +442,11 @@ def capture_records( continue warnings = secret_value_warnings(text) safe_notes, _, _ = redact_secret_values(notes) - # What Accept will actually save: mined the same way accept-capture - # mines, so the preview is the proposal, not a guess. - mined = propose_memories_from_text(notes, [], source=rel, limit=3) + # What Accept will actually save: mine from the user's own turns + # (proposal source) the same way accept-capture does, so the preview + # matches the outcome and never surfaces the assistant's prose. + mining_text = capture_proposal_source(text) or notes + mined = propose_memories_from_text(mining_text, [], source=rel, limit=3) proposal_items = mined.get("proposals") if isinstance(mined.get("proposals"), list) else [] proposal_previews = [] for proposal in proposal_items[:3]: @@ -407,6 +470,8 @@ def capture_records( "snippet": re.sub(r"\s+", " ", safe_notes).strip()[:180], "proposal_count": len(proposal_items), "proposals": proposal_previews, + "decision_trail": capture_decision_trail(text), + "mined_from_user_turns": capture_proposal_source(text) is not None, "commands": command_builder(rel), }) records.sort(key=lambda item: (str(item["date_captured"]), str(item["path"])), reverse=True) diff --git a/mcp_package/link_core/web_memory.py b/mcp_package/link_core/web_memory.py index fb0d662d..223b8ade 100644 --- a/mcp_package/link_core/web_memory.py +++ b/mcp_package/link_core/web_memory.py @@ -249,6 +249,36 @@ def render_capture_card(capture: dict[str, object]) -> str: + html.escape(", ".join(warnings)) + "

" ) + + # What Accept will save, and how Link decided — the pipeline made reviewable. + proposals = capture.get("proposals") if isinstance(capture.get("proposals"), list) else [] + proposals_html = "" + if proposals: + rows = "".join( + f'
  • {html.escape(str(p.get("memory") or ""))}' + f' {html.escape(str(p.get("memory_type") or ""))}' + f'{" · " + html.escape(str(p.get("confidence"))) if p.get("confidence") else ""}
  • ' + for p in proposals if isinstance(p, dict) + ) + attribution = ( + "mined from your own turns" + if capture.get("mined_from_user_turns") + else "mined from the full session" + ) + proposals_html = ( + f'

    Will save ({attribution}):

    ' + f'
      {rows}
    ' + ) + + trail = capture.get("decision_trail") if isinstance(capture.get("decision_trail"), list) else [] + trail_html = "" + if trail: + steps = "".join(f"
  • {html.escape(str(step))}
  • " for step in trail) + trail_html = ( + '
    How Link read this session' + f'
      {steps}
    ' + ) + commands = capture.get("commands") or {} actions = "".join( f'
    {html.escape(label)}' @@ -267,6 +297,8 @@ def render_capture_card(capture: dict[str, object]) -> str: f'
    {html.escape(meta)}
    ' f'

    {path}

    ' f'{warning_html}' + f'{proposals_html}' + f'{trail_html}' f'
    {actions}
    ' '' ) diff --git a/tests/test_agent_hooks_core.py b/tests/test_agent_hooks_core.py index 1dd32afa..82531ad3 100644 --- a/tests/test_agent_hooks_core.py +++ b/tests/test_agent_hooks_core.py @@ -242,6 +242,21 @@ def test_extract_transcript_bounds_output_to_most_recent_messages(self): self.assertIn("message 49", text) self.assertNotIn("message 0:", text) + def test_keep_head_preserves_opening_turns_in_a_long_session(self): + with tempfile.TemporaryDirectory() as temp: + transcript = Path(temp) / "transcript.jsonl" + lines = [_transcript_line("user", "OPENING RULE: only deploy through the release script.")] + lines += [_transcript_line("user", f"filler step {i}: " + ("x" * 400)) for i in range(50)] + transcript.write_text("\n".join(lines), encoding="utf-8") + + # Recency-only drops the opening; keep_head preserves it. + recency = extract_transcript_text(transcript, max_chars=2000) + headed = extract_transcript_text(transcript, max_chars=2000, keep_head=True) + + self.assertNotIn("OPENING RULE", recency) + self.assertIn("OPENING RULE", headed) + self.assertIn("filler step 49", headed) + def test_extract_transcript_drops_link_injected_content(self): with tempfile.TemporaryDirectory() as temp: transcript = Path(temp) / "transcript.jsonl" diff --git a/tests/test_capture_core.py b/tests/test_capture_core.py index 1bf1a119..ac65cfb6 100644 --- a/tests/test_capture_core.py +++ b/tests/test_capture_core.py @@ -6,10 +6,12 @@ from mcp_package.link_core.capture import ( capture_accept_memory_args, capture_accept_payload, + capture_decision_trail, capture_filename, capture_inbox, capture_notes_from_markdown, capture_proposal_selection, + capture_proposal_source, capture_records, capture_review_summary, capture_title, @@ -566,3 +568,48 @@ def test_inbox_items_carry_proposal_previews(self): first = item["proposals"][0] self.assertIn("release script", first["memory"]) self.assertEqual(first["memory_type"], "preference") + + +class CaptureProvenanceTests(unittest.TestCase): + def test_capture_records_proposal_source_and_trail(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + record = write_session_capture( + root, + text="User: hi\n\nAssistant: here is a long helpful explanation.", + source="session-end", + proposal_text="User: from now on I only push to develop.", + decision_trail=["Read the session: kept 4 messages.", "Stored 1 proposal."], + ) + text = (root / record["path"]).read_text(encoding="utf-8") + + source = capture_proposal_source(text) + self.assertIn("only push to develop", source) + self.assertNotIn("helpful explanation", source) + self.assertEqual(len(capture_decision_trail(text)), 2) + + def test_accept_mines_user_turns_not_assistant_prose(self): + from mcp_package.link_core.memory import propose_memories_from_text + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + record = write_session_capture( + root, + text=( + "User: help me name branches.\n\n" + "Assistant: I always recommend feat/short-topic naming for clarity." + ), + source="session-end", + proposal_text="User: from now on I only push to develop.", + ) + + def builder(notes, source, limit, project): + return propose_memories_from_text(notes, [], source=source, limit=limit) + + selection = capture_proposal_selection( + root, record["path"], index=1, propose_memories=builder, + ) + memory = str(selection["proposal"]["memory"]) + + self.assertIn("only push to develop", memory) + self.assertNotIn("feat/short-topic", memory) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 3b67eedd..aa8893ef 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -3171,7 +3171,7 @@ def test_session_end_explain_prints_decision_trail(self): self.assertEqual(code, 0) text = out.getvalue() - self.assertIn("dropped 1 carrying Link's own output", text) + self.assertIn("dropped 1 carrying Link's own injected output", text) self.assertIn("trivial session", text) def test_conflict_output_offers_supersede_command(self): From 3ec7c6c1a26967c6c707da976fa22d398d4a4f41 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 12:54:53 -0600 Subject: [PATCH 06/29] LinkBar: capture rows expand to 'How Link read this session' decision trail --- apps/LinkBar/Sources/LinkBar/Models.swift | 8 ++++++++ apps/LinkBar/Sources/LinkBar/PopoverView.swift | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift index a69405e8..023f7784 100644 --- a/apps/LinkBar/Sources/LinkBar/Models.swift +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -51,6 +51,14 @@ struct CaptureItem: Decodable, Identifiable { 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 { diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index 8484b4f5..6eb7fb05 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -283,6 +283,23 @@ struct PopoverView: View { .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 { From 4521c95a9ac214c411c72de4f46056b0b848d27b Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 14:18:13 -0600 Subject: [PATCH 07/29] LinkBar v0.6: living overview, activity pulse, and review-pass fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inch-by-inch review of every surface plus RepoBar-inspired features. Bugs fixed: - start() ran on every popover open, stacking a 5-min timer and rebuilding watchers each time; now guarded to run once per launch. - Watchers for raw/memory-captures failed silently on a fresh workspace (directory not yet created) and stayed blind until restart; they now self-heal after the first capture creates the dir. - LogEntry.id was timestamp+operation, which collides when one command writes twice in a second and made ForEach drop rows; id now includes the description. - memory-log emits oldest-first; "Recent activity" and the "Latest:" line wanted newest-first — now reversed. - Dashboard viewer probe trusted any responder on :3000; it now requires the Link viewer page (200 + "Link" in body) so a foreign dev server on that port isn't mistaken for ours. - Bundle CFBundleShortVersionString was 0.1.0 while the UI said 0.5; both are now 0.6.0 from one LinkBrand.version constant. Features: - Idle state replaced with a living overview: active/total memories, wiki pages, and to-review counts as stat chips, plus a 14-day activity sparkline and the latest operation with relative time. - Multi-proposal captures get an Accept menu (pick which proposal); single-proposal stays a one-click Accept. - Recall rows gain Copy text; the search field gains a clear button. - Capture timestamps show local relative time ("2h ago") instead of raw UTC. - Menu-bar icon shows "!" when the workspace runtime is stale (repair is one click) even when nothing is pending. Verified via light/dark snapshots against both released lnk (graceful fallback: no proposals/trail) and dev lnk (full 1.7 rendering: "Will save" preview, decision-trail disclosure, provenance). Installed and running as 0.6.0. --- apps/LinkBar/Scripts/bundle.sh | 2 +- .../Sources/LinkBar/DesignSystem.swift | 45 ++++++++++ apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 5 ++ apps/LinkBar/Sources/LinkBar/LinkStore.swift | 75 +++++++++++++--- apps/LinkBar/Sources/LinkBar/Models.swift | 55 +++++++++--- .../LinkBar/Sources/LinkBar/PopoverView.swift | 89 +++++++++++++++---- 6 files changed, 229 insertions(+), 42 deletions(-) diff --git a/apps/LinkBar/Scripts/bundle.sh b/apps/LinkBar/Scripts/bundle.sh index c7a2feea..e7ea5df9 100755 --- a/apps/LinkBar/Scripts/bundle.sh +++ b/apps/LinkBar/Scripts/bundle.sh @@ -19,7 +19,7 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleDisplayNameLinkBar CFBundleExecutableLinkBar CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.0 + CFBundleShortVersionString0.6.0 LSMinimumSystemVersion14.0 LSUIElement CFBundleIconFileAppIcon diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift index 83b52461..ee63b76b 100644 --- a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift +++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift @@ -3,6 +3,9 @@ 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 = "0.6" + /// 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 @@ -73,3 +76,45 @@ struct Wordmark: View { } } } + + +/// 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) + } +} diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift index 4de7c84b..fd54e3c8 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -26,6 +26,11 @@ struct LinkBarApp: App { if store.pendingCount > 0 { Text("\(store.pendingCount)") .font(.system(size: 11, weight: .semibold)) + } else if store.runtimeWarning != nil { + // Attention without a count: the workspace runtime is + // stale and one click fixes it. + Text("!") + .font(.system(size: 11, weight: .bold)) } } .onAppear { Self.snapshotIfRequested(store: store) } diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index 996390bf..a69e0b6f 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -22,6 +22,7 @@ final class LinkStore: ObservableObject { @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 @@ -29,12 +30,33 @@ final class LinkStore: ObservableObject { (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 @@ -45,21 +67,32 @@ final class LinkStore: ObservableObject { /// Watch the paths that change when memory changes: captures land in /// raw/memory-captures, memories in wiki/memories, log in wiki. - private func startWatching() { + private var watchPaths: [String] { let root = LinkCLI.workspace - let paths = [ + return [ root, (root as NSString).appendingPathComponent("raw/memory-captures"), (root as NSString).appendingPathComponent("wiki/memories"), (root as NSString).appendingPathComponent("wiki"), ] - watchers = paths.compactMap { path in + } + + 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() @@ -76,7 +109,7 @@ final class LinkStore: ObservableObject { 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", "4"]) + let log = try? LinkCLI.runJSON(MemoryLog.self, ["memory-log", workspace, "--json", "--limit", "200"]) let status = try? LinkCLI.runJSON(StatusPayload.self, ["status", workspace, "--json"]) await MainActor.run { if inbox == nil && captures == nil { @@ -86,14 +119,16 @@ final class LinkStore: ObservableObject { } self.inbox = inbox ?? self.inbox self.captures = captures ?? self.captures - self.activity = log?.entries ?? self.activity + 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() } } } @@ -132,9 +167,9 @@ final class LinkStore: ObservableObject { act(["archive-memory", item.name, LinkCLI.workspace]) } - /// Accept a session capture's first proposal into reviewed memory flow. - func acceptCapture(_ capture: CaptureItem) { - act(["accept-capture", capture.path, LinkCLI.workspace, "--index", "1"]) + /// 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)"]) } func deleteCapture(_ capture: CaptureItem) { @@ -234,6 +269,19 @@ final class LinkStore: ObservableObject { } } + /// 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)) } @@ -279,12 +327,15 @@ final class LinkStore: ObservableObject { } private static func viewerResponds() async -> Bool { - var request = URLRequest(url: URL(string: "http://127.0.0.1:3000/")!) + var request = URLRequest(url: URL(string: "http://127.0.0.1:3000/memory")!) request.timeoutInterval = 0.5 - request.httpMethod = "HEAD" do { - let (_, response) = try await URLSession.shared.data(for: request) - return (response as? HTTPURLResponse) != nil + 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 } diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift index 023f7784..aa5b1135 100644 --- a/apps/LinkBar/Sources/LinkBar/Models.swift +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -66,18 +66,37 @@ struct CaptureItem: Decodable, Identifiable { } /// Capture filenames start with a UTC stamp (20260712T165329Z-…); - /// showing it disambiguates same-titled sessions. + /// 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 } - let month = Int(name.dropFirst(4).prefix(2)) ?? 0 - let day = name.dropFirst(6).prefix(2) - let hour = name.dropFirst(9).prefix(2) - let minute = name.dropFirst(11).prefix(2) - let months = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - guard (1...12).contains(month) else { return nil } - return "\(day) \(months[month]) \(hour):\(minute) UTC" + 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) } } @@ -90,7 +109,11 @@ struct LogEntry: Decodable, Identifiable { let operation: String let description: String? - var id: String { timestamp + operation } + // 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 { @@ -126,6 +149,18 @@ struct StatusPayload: Decodable { 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 { diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index 6eb7fb05..eb52d361 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -123,6 +123,18 @@ struct PopoverView: View { .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) @@ -180,11 +192,12 @@ struct PopoverView: View { .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 or right-click to open the memory file") + .help("Double-click to open · right-click to copy") } } } @@ -306,11 +319,25 @@ struct PopoverView: View { Button("Reveal in Finder") { store.revealCapture(capture) } } Spacer(minLength: 6) - Button("Accept") { store.acceptCapture(capture) } - .buttonStyle(.borderedProminent) - .tint(LinkBrand.rust) + 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) - .help("Accept the first proposal into reviewed memory") + .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") } @@ -349,20 +376,44 @@ struct PopoverView: View { } } - /// Everything reviewed, nothing captured, nothing searched: say so nicely. + /// 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(spacing: 6) { - Image(systemName: "checkmark.seal") - .font(.system(size: 22, weight: .light)) - .foregroundStyle(LinkBrand.rust.opacity(0.8)) - Text("All reviewed.") - .font(.system(size: 12.5, weight: .medium)) - Text("Your agents remember you — say something worth keeping.") - .font(.caption) - .foregroundStyle(.secondary) + 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) } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) } private var footer: some View { @@ -393,7 +444,7 @@ struct PopoverView: View { .lineLimit(1) .truncationMode(.head) Spacer() - Text("LinkBar 0.5" + (store.linkVersion.isEmpty ? "" : " · Link \(store.linkVersion)")) + Text("LinkBar \(LinkBrand.version)" + (store.linkVersion.isEmpty ? "" : " · Link \(store.linkVersion)")) .font(.system(size: 10.5)) .foregroundStyle(.tertiary) Button("Quit") { NSApplication.shared.terminate(nil) } @@ -461,7 +512,7 @@ struct SettingsPane: View { HStack { if !store.linkVersion.isEmpty { - Text("LinkBar 0.5 · Link \(store.linkVersion)") + Text("LinkBar \(LinkBrand.version) · Link \(store.linkVersion)") .font(.caption) .foregroundStyle(.tertiary) } From 079ccc946f72eba59ae6760d931a99fd2ec8dbf7 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 14:20:06 -0600 Subject: [PATCH 08/29] LinkBar 1.0: launch the new app at 1.0, not 0.6 --- apps/LinkBar/Scripts/bundle.sh | 2 +- apps/LinkBar/Sources/LinkBar/DesignSystem.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/LinkBar/Scripts/bundle.sh b/apps/LinkBar/Scripts/bundle.sh index e7ea5df9..af5d528e 100755 --- a/apps/LinkBar/Scripts/bundle.sh +++ b/apps/LinkBar/Scripts/bundle.sh @@ -19,7 +19,7 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleDisplayNameLinkBar CFBundleExecutableLinkBar CFBundlePackageTypeAPPL - CFBundleShortVersionString0.6.0 + CFBundleShortVersionString1.0.0 LSMinimumSystemVersion14.0 LSUIElement CFBundleIconFileAppIcon diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift index ee63b76b..5e7f08ca 100644 --- a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift +++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift @@ -4,7 +4,7 @@ import SwiftUI /// 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 = "0.6" + 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. From 6c283380f21e6471a5c84938cf7673d785b41160 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 14:36:32 -0600 Subject: [PATCH 09/29] Rank proposals by durability; single-hero Quick Start; quieter onboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-user walkthrough found the automatic loop's worst first impression: a one-click Accept (LinkBar) and accept-capture --index 1 landed on whichever proposal came first in the transcript, which is often a throat-clearing preamble ("I want to set some conventions…") rather than the rule ("From now on I only deploy on Fridays…"). Both classify as preferences at score 90, so confidence alone can't separate them. - memory_durability_rank scores concrete directives (an action verb, or only/always/never/by default) above statements that are merely about making rules (want/need to set/establish + conventions/guidelines/…). propose_memories_from_text stable-sorts by it, so index 1 is the substance. Bare temporal phrases ("from now on", "going forward") don't count as concrete — they attach to preambles just as easily. Ordering only; every proposal still appears for review. - README Quick Start collapses three parallel entry points (proof / try + serve / onboard x5) into one two-command hero path. - lnk onboard collapses a fresh workspace's dozen scaffold lines into a one-line summary; targeted repairs on an existing workspace still list. --- CHANGELOG.md | 2 ++ README.md | 53 +++++++++------------------- mcp_package/link_core/cli_runtime.py | 11 ++++-- mcp_package/link_core/memory.py | 49 ++++++++++++++++++++++--- tests/test_memory_core.py | 27 ++++++++++++++ 5 files changed, 100 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b1cde8..bd0e16cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Secrets are refused at the memory gate: `remember` (CLI and both MCP tools) now detects credential-shaped text — API-token patterns plus a conservative password heuristic ("Zk9#mango42", "the wifi password is …", "PIN 1234") — and refuses with a pointer to a password manager; `--allow-secret` overrides when the text truly isn't one. Memory pages are plain files injected into every connected agent's session, so a saved credential leaks by design. - Forgetting now forgets the log too: `forget-memory` scrubs the memory's title and name from past `wiki/log.md` entries, replacing them with `[forgotten memory]`. The log's tamper-evident hash chain is re-anchored and the redaction declares itself as a `redact-log` entry — never silent — and integrity verification passes afterwards. - The capture inbox shows what Accept will actually save: each capture in `capture-inbox --json` (and the text listing) now carries mined proposal previews (title, memory text, type, confidence) — the same deterministic miner accept-capture uses, with secret-looking values redacted from previews. +- Captured proposals are ranked by durability, so the one a one-click Accept saves (and `accept-capture --index 1`) is the substance, not a meta-preamble. A session that says "I want to set some conventions… From now on I only deploy on Fridays…" used to surface the vague "wants to set conventions" first (it classifies as a preference just as strongly); proposals now sort concrete directives ("I only deploy…", "always run…", "I prefer…") ahead of statements that are merely *about* making rules. Ordering only — every proposal still appears for review. +- Onboarding reads cleaner: `lnk onboard` on a fresh workspace collapses the dozen "created wiki/…" scaffold lines into one summary, and the README Quick Start is now a single two-command hero path (`lnk proof` then `lnk onboard --agent --write`) instead of three parallel entry points. - Core hardened against hostile and degenerate inputs (adversarial fuzz of the automatic pipeline): frontmatter values now collapse newlines and control characters, closing a field-injection path where a title containing `\ntitle: injected` (or any `key: value` line) wrote extra frontmatter fields that the parser honored; slugs are capped at 80 chars so pathological titles can't crash writes with filesystem name limits; concurrent session-end hooks no longer race on capture filenames (atomic O_EXCL claim — previously simultaneous hook fires could silently lose a capture); applies_when project conditions normalize to the slug recall compares against and reject unslug-able values. 540-call MCP fuzz now returns clean JSON errors with zero escaped exceptions, and `validate` passes after every hostile write. - Session captures are now reviewable and correctly attributed. Three fixes to how Link reads a session: (1) standing rules survive long sessions — the hook mines a head+tail window instead of only the most recent ~6k characters, so "from now on I only deploy through the release script" said early in a two-hour session is still captured; (2) accept-time re-mining reads the user's own turns only (recorded in a `## Proposal Source` block), closing a path where the assistant's prose could be re-proposed as your preference; (3) every capture records a decision trail (`## How Link Read This Session`) — messages kept, echoes dropped, proposals found — surfaced on the dashboard Captures page and in `capture-inbox --json` alongside the proposal previews and a "mined from your own turns" attribution flag. - Session captures now carry retrieval context end to end: when the session-end hook proposes a memory, the neighboring sentences around the claim's origin travel with the proposal, through accept-capture, into the memory page's `context` frontmatter — the same ±1-neighbor window that lifted LoCoMo hit@10 0.685→0.737 in the retrieval benchmark. Context helps recall *find* the memory (scoring + embeddings) but is never part of the claim: echo/duplicate/conflict checks, slim output, and the visible page body all exclude it. Also exposed explicitly as `lnk remember --context` and the `context` parameter on both MCP remember tools; capped at 600 characters. diff --git a/README.md b/README.md index 60d7c8bc..1acae4e8 100644 --- a/README.md +++ b/README.md @@ -121,19 +121,18 @@ Full methodology and reproduction steps: ## Quick Start -Start with the memory proof. It creates a clean local workspace, writes one -reviewed memory, and proves that the same memory can be recalled through CLI, -official skills, and MCP. No web server is required for the proof. +Two commands: see it work, then make it yours. ```bash brew install gowtham0992/link/link -lnk proof +lnk proof # see the promise (~1 second, no setup) +lnk onboard --agent claude-code --write # wire your agent for real memory ``` -The installed command is `lnk` because `link` is already a POSIX/macOS system -utility. From a source checkout, use `python3 link.py ...` instead. - -You should see: +`lnk proof` creates a throwaway workspace, writes one reviewed memory, and +recalls it through the same path the CLI, skills, and MCP use — the core +promise (one local memory, reusable by different agents, no cloud profile) in +one second: ```text Cross-agent memory continuity works @@ -142,38 +141,20 @@ Recall: found through the same bounded recall path used by CLI, skills, and MCP. Result: proof passed ``` -That is the core promise: one local memory, reusable by different agents, -without a hidden cloud profile. +`lnk onboard --agent claude-code --write` then creates `~/link`, provisions the +MCP runtime, and wires the agent — including the session hooks that capture +memory automatically as you work (swap `claude-code` for `codex`, `cursor`, +`kiro`, `copilot`, `antigravity`, or others). Drop `--write` to preview the +config without touching anything, or drop `--agent` to just create the +workspace. -Then run the richer demo when you want the UI, graph, source pages, and query -packets: - -```bash -lnk try -lnk serve link-demo -``` +The installed command is `lnk` because `link` is already a POSIX/macOS system +utility. From a source checkout, use `python3 link.py ...` instead. -`lnk try` creates the demo, checks readiness, runs compact query/brief examples, -and prints the first agent prompts. Windows, source checkout, MCP-only, and -skill-first setup live in the +Want the UI, graph, and source pages first? `lnk try && lnk serve link-demo`. +Windows, source checkout, MCP-only, and skill-first paths are in the [First 10 Minutes guide](https://gowtham0992.github.io/link/getting-started.html). -When you are ready to use Link for real memory, run one guided command: - -```bash -lnk onboard -lnk onboard --first-memory "I prefer concise release notes" -lnk onboard --seed-project . -lnk onboard --agent codex -lnk onboard --agent codex --write -``` - -`lnk onboard` creates or repairs `~/link`, checks health, prints the exact agent -prompts to try, and previews MCP wiring for Codex, Claude Code, Cursor, Kiro, -VS Code, Copilot, Antigravity, and other supported clients. It only writes an -agent config when you pass `--write`. Add `--seed-project .` from inside a repo -when you want onboarding to create the first source-backed project context page. - Or seed your current repo as a separate step so the first real recall is not empty: ```bash diff --git a/mcp_package/link_core/cli_runtime.py b/mcp_package/link_core/cli_runtime.py index c4f7cbff..e84dcaf9 100644 --- a/mcp_package/link_core/cli_runtime.py +++ b/mcp_package/link_core/cli_runtime.py @@ -357,8 +357,15 @@ def render_onboard_text(payload: Mapping[str, object]) -> tuple[int, str]: ] fixes = payload.get("fixes") if isinstance(fixes, Sequence) and not isinstance(fixes, (str, bytes)) and fixes: - lines.append("- safe repairs:") - lines.extend(f" - {item}" for item in fixes) + # A fresh workspace reports every scaffolded directory/file — a dozen + # lines a first-timer doesn't need. Collapse the bulk case to one + # line; keep a short list of targeted repairs on an existing + # workspace, where each line is actually meaningful. + if len(fixes) > 4: + lines.append(f"- scaffolded a fresh workspace ({len(fixes)} items)") + else: + lines.append("- safe repairs:") + lines.extend(f" - {item}" for item in fixes) lines.extend(["", "First memory"]) if memory: diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index e13c9b31..3deb563e 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -3085,6 +3085,46 @@ def extract_procedure_candidates(text: str, max_candidates: int = 3) -> list[dic return candidates +# A sentence can classify as a preference yet carry no durable substance — +# "I want to set some conventions" is *about* making rules, not itself a +# rule. These rank such meta-preambles below concrete directives so the +# proposal a one-click Accept lands on is the useful one, not the throat- +# clearing that happened to come first in the transcript. +_META_PREAMBLE_RE = re.compile( + r"(?i)\b(?:want|wanted|like|need|going|trying|hoping|planning)\s+to\s+" + r"(?:set|establish|define|create|make|figure|think|discuss|talk|" + r"standardi[sz]e|nail|sort|work)\b" + r"|\b(?:some|a few|certain|our|the)\s+" + r"(?:conventions?|guidelines?|standards?|rules?|practices?|norms?|processes?)\b" + r"|\bhow\s+we\s+(?:work|operate|do things)\b" +) +# Deliberately excludes bare temporal phrases ("from now on", "going +# forward") — they attach just as easily to a vague preamble ("I want to +# set conventions going forward") as to a real rule, so they can't earn the +# concrete bonus on their own. A genuine directive has an action verb or an +# absolute qualifier. +_CONCRETE_DIRECTIVE_RE = re.compile( + r"(?i)\b(?:only|always|never|by default|whenever)\b" + r"|\b(?:i|we)\s+(?:prefer|use|deploy|merge|commit|run|ship|release|test|" + r"avoid|write|require|keep|review|pin|target)\b" +) + + +def memory_durability_rank(memory: str) -> int: + """Higher = more durably useful as a standalone memory. + + Used only to order proposals within a capture (not to accept/reject): + concrete directives outrank vague meta-statements about wanting rules. + """ + text = str(memory or "").strip() + rank = 0 + if _CONCRETE_DIRECTIVE_RE.search(text): + rank += 2 + if _META_PREAMBLE_RE.search(text) and not _CONCRETE_DIRECTIVE_RE.search(text): + rank -= 2 + return rank + + def propose_memories_from_text( text: str, records: Iterable[Mapping[str, object]], @@ -3133,8 +3173,6 @@ def propose_memories_from_text( } proposal["primary_action"] = memory_proposal_action(proposal, command_target=command_target) proposals.append(proposal) - if len(proposals) >= limit: - break segments = memory_proposal_segments(text) for index, segment in enumerate(segments): classified = classify_memory_segment(segment) @@ -3199,8 +3237,11 @@ def propose_memories_from_text( } proposal["primary_action"] = memory_proposal_action(proposal, command_target=command_target) proposals.append(proposal) - if len(proposals) >= limit: - break + # Rank the most durably useful proposal first so a one-click Accept (and + # accept-capture --index 1) lands on the substance, not a meta-preamble + # that happened to come first. Stable: equal ranks keep transcript order. + proposals.sort(key=lambda p: memory_durability_rank(str(p.get("memory", ""))), reverse=True) + proposals = proposals[:limit] return { "proposed": True, "source": source, diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index b71f1cb7..344e33e0 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -25,6 +25,7 @@ memory_profile, memory_review_issues, memory_records, + memory_durability_rank, propose_memories_from_text, recall_memories, recall_state, @@ -1438,3 +1439,29 @@ def test_slugify_caps_length_for_filesystem_limits(self): self.assertFalse(slug.endswith("-")) # short slugs unchanged self.assertEqual(slugify("My Cool Title"), "my-cool-title") + + +class ProposalDurabilityRankingTests(unittest.TestCase): + def test_concrete_rule_outranks_meta_preamble(self): + self.assertGreater( + memory_durability_rank("From now on I only deploy on Fridays"), + memory_durability_rank("I want to set some conventions for how we work going forward"), + ) + + def test_one_click_accept_lands_on_substance_not_preamble(self): + text = ( + "I want to set some conventions for how we work on the payments service going forward. " + "From now on I only deploy the payments service on Fridays, never mid-week. " + "I prefer squash merges for that repo." + ) + proposals = propose_memories_from_text(text, [])["proposals"] + # The default accept (--index 1 / one-click) must not be the preamble. + self.assertNotIn("set some conventions", proposals[0]["memory"]) + # The vague preamble ranks last, not first. + self.assertIn("set some conventions", proposals[-1]["memory"]) + + def test_ranking_is_stable_for_equal_substance(self): + # Two concrete rules keep transcript order (both rank equally). + text = "I only merge with squash commits. I always run the linter before pushing." + proposals = propose_memories_from_text(text, [])["proposals"] + self.assertIn("squash", proposals[0]["memory"]) From 347e5bb6bdd8419fc3c202b6403ae5c6bef95718 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 20:24:17 -0600 Subject: [PATCH 10/29] LinkBar: tabbed UI with a Status dashboard for every Link surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkBar showed your memory but was silent about Link's own health — so a stale MCP runtime, unwired hooks, or a missing semantic tier would quietly degrade recall with no signal. New Status tab makes the whole plumbing visible and self-healing. - Tabbed shell: Inbox · Status · Settings (slim rust segmented bar; Inbox carries the pending badge, Status shows an amber dot when a surface needs attention). Settings moves from a sheet-like pane to a proper tab. - Status dashboard: a health row per surface — CLI, Workspace, MCP, Hooks (Claude Code), Recall power (semantic tier + rerank), Viewer — each a colored dot + one-line state, fed by status/verify-mcp/ semantic --json plus a direct read of the agent's hook config. Heavy probes (verify-mcp, semantic) are gated to once/15s and run after the fast data paints, so the popover stays instant. - One-click remediation on every unhealthy row: Refresh (stale runtime), Fix (MCP version drift), Wire (Claude Code hooks), Enable (semantic recall), Open (viewer). - Menu-bar icon shows a subtle amber dot when any surface is unhealthy even with an empty review inbox — Link tells you before recall quietly gets worse. Verified via light/dark snapshots against a real workspace: it correctly flags this machine's stale MCP (1.5.0 ≠ 1.6.0) with a Fix button and lexical-only recall with an Enable button. --- .../Sources/LinkBar/DesignSystem.swift | 54 +++++ apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 12 +- apps/LinkBar/Sources/LinkBar/LinkStore.swift | 187 ++++++++++++++++++ apps/LinkBar/Sources/LinkBar/Models.swift | 91 +++++++++ .../LinkBar/Sources/LinkBar/PopoverView.swift | 103 ++++++++-- 5 files changed, 428 insertions(+), 19 deletions(-) diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift index 5e7f08ca..370b634e 100644 --- a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift +++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift @@ -118,3 +118,57 @@ struct StatChip: View { .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) + } +} diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift index fd54e3c8..549b780c 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -26,11 +26,13 @@ struct LinkBarApp: App { if store.pendingCount > 0 { Text("\(store.pendingCount)") .font(.system(size: 11, weight: .semibold)) - } else if store.runtimeWarning != nil { - // Attention without a count: the workspace runtime is - // stale and one click fixes it. - Text("!") - .font(.system(size: 11, weight: .bold)) + } 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) } diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index a69e0b6f..0c7bc092 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -26,6 +26,13 @@ final class LinkStore: ObservableObject { @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 + private var lastHealthAt = Date.distantPast + var pendingCount: Int { (inbox?.reviewCount ?? 0) + (captures?.count ?? 0) } @@ -130,7 +137,136 @@ final class LinkStore: ObservableObject { self.busy = false self.healWatchersIfNeeded() } + // 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() + } + } + + /// 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). + 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 } } func recall(_ query: String) { @@ -244,6 +380,57 @@ final class LinkStore: ObservableObject { } } + // 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). + func setupSemantic() { + runFix(["semantic", LinkCLI.workspace, "--setup"], + running: "Installing semantic recall…", + done: "Semantic recall ready.") + } + + /// Bring link-mcp in the workspace venv to Link's version. + func upgradeMCP() { + // A workspace runtime refresh re-provisions the pinned venv; simplest + // one-click path that matches how connect/onboard heal MCP drift. + repairRuntime() + } + + /// Wire Claude Code's session hooks (capture on session end, brief on + /// session start) — the automatic loop, one click. + func wireClaudeHooks() { + runFix(["connect", "claude-code", LinkCLI.workspace, "--hooks", "--write"], + running: "Wiring Claude Code hooks…", + done: "Hooks wired — new sessions capture automatically.") + } + + func openInstallDocs() { + NSWorkspace.shared.open(URL(string: "https://github.com/gowtham0992/link#quick-start")!) + } + + /// Run a remediation command, flash progress, and refresh health after. + private func runFix(_ args: [String], running: String, done: String) { + busy = true + showFlash(running, tone: .info) + Task.detached(priority: .userInitiated) { + do { + _ = try LinkCLI.run(args) + await MainActor.run { + self.showFlash(done, tone: .success) + self.busy = false + self.refresh() + self.refreshHealth() + } + } catch { + await MainActor.run { + self.lastError = String(describing: error) + self.busy = false + } + } + } + } + func revealMemory(named name: String) { let path = (LinkCLI.workspace as NSString) .appendingPathComponent("wiki/memories/\(name).md") diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift index aa5b1135..815d608a 100644 --- a/apps/LinkBar/Sources/LinkBar/Models.swift +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -168,3 +168,94 @@ struct RememberResult: Decodable { 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 } +} diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index eb52d361..9f023231 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -1,9 +1,13 @@ import SwiftUI struct PopoverView: View { + enum Tab { case inbox, status, settings } + @EnvironmentObject var store: LinkStore @State private var query = "" - @State private var showSettings = false + // Snapshot aid: LINKBAR_TAB=status opens straight to the Status tab. + @State private var tab: Tab = + ProcessInfo.processInfo.environment["LINKBAR_TAB"] == "status" ? .status : .inbox private var isFullyIdle: Bool { (store.inbox?.items.isEmpty ?? true) @@ -13,20 +17,76 @@ struct PopoverView: View { } var body: some View { - Group { - if showSettings { - SettingsPane(done: { showSettings = false }) - .environmentObject(store) - } else { - mainContent + 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 .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) } - private var mainContent: some View { + // MARK: tab bar + + private var tabBar: some View { + HStack(spacing: 4) { + tabButton("Inbox", .inbox, badge: store.pendingCount) + 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) { - header if let warning = store.runtimeWarning { runtimeBanner(warning) } @@ -45,10 +105,27 @@ struct PopoverView: View { if !store.activity.isEmpty && !isFullyIdle { activitySection } - footer } .padding(LinkBrand.pad) - .animation(.easeOut(duration: 0.18), value: store.pendingCount) + } + + // 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 @@ -71,11 +148,9 @@ struct PopoverView: View { } toolbarButton("arrow.clockwise", help: "Refresh (⌘R)") { store.refresh() + store.refreshHealth() } .keyboardShortcut("r", modifiers: .command) - toolbarButton("gearshape", help: "Settings") { - showSettings = true - } } } From e8a2a927f75f68c06cfe4404ba282426826585c2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 12 Jul 2026 20:30:41 -0600 Subject: [PATCH 11/29] LinkBar: Status fixes verify their outcome instead of claiming success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Enable" on the Recall row flashed "Semantic recall ready." and then the row stayed "Lexical only" — because the fix flashed success on exit 0 without checking whether anything changed. On a released (pre-1.7) CLI, `semantic --setup` only prints the manual pip steps and exits 0, so nothing was installed. Every remediation now confirms the real outcome and flashes the truth: - Enable (semantic): runs --setup, re-reads `semantic --json`, and reports "ready — " only if it actually enabled, else the exact manual command to run. - Fix (MCP): runs the precise upgrade command verify-mcp emits (venv pip install --upgrade link-mcp==) via a new LinkCLI.runRaw, re-checks verify-mcp, and reports success only if it's ready — not a workspace init that never touched the venv. - Wire (hooks): re-reads the settings file to confirm the hook landed. No more optimistic flashes; the dot and the message always agree. --- apps/LinkBar/Sources/LinkBar/LinkCLI.swift | 20 +++++ apps/LinkBar/Sources/LinkBar/LinkStore.swift | 94 +++++++++++++------- 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/apps/LinkBar/Sources/LinkBar/LinkCLI.swift b/apps/LinkBar/Sources/LinkBar/LinkCLI.swift index b83a89ca..6c3d3fb2 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkCLI.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkCLI.swift @@ -63,6 +63,26 @@ enum LinkCLI { 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 } diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index 0c7bc092..e2cde090 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -173,7 +173,7 @@ final class LinkStore: ObservableObject { /// 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). - private static func claudeHooksAreWired() -> Bool { + 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") @@ -384,53 +384,79 @@ final class LinkStore: ObservableObject { /// 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() { - runFix(["semantic", LinkCLI.workspace, "--setup"], - running: "Installing semantic recall…", - done: "Semantic recall ready.") + 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. + /// 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() { - // A workspace runtime refresh re-provisions the pinned venv; simplest - // one-click path that matches how connect/onboard heal MCP drift. - repairRuntime() + 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. + /// session start) — the automatic loop, one click — then confirm they + /// actually landed in the settings file. func wireClaudeHooks() { - runFix(["connect", "claude-code", LinkCLI.workspace, "--hooks", "--write"], - running: "Wiring Claude Code hooks…", - done: "Hooks wired — new sessions capture automatically.") - } - - func openInstallDocs() { - NSWorkspace.shared.open(URL(string: "https://github.com/gowtham0992/link#quick-start")!) - } - - /// Run a remediation command, flash progress, and refresh health after. - private func runFix(_ args: [String], running: String, done: String) { busy = true - showFlash(running, tone: .info) + showFlash("Wiring Claude Code hooks…", tone: .info) Task.detached(priority: .userInitiated) { - do { - _ = try LinkCLI.run(args) - await MainActor.run { - self.showFlash(done, tone: .success) - self.busy = false - self.refresh() - self.refreshHealth() - } - } catch { - await MainActor.run { - self.lastError = String(describing: error) - self.busy = false - } + _ = 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") From 788a7ec4c4b56a333bb7d68e1fc5e1ee2c7e05ce Mon Sep 17 00:00:00 2001 From: Gowtham Date: Fri, 17 Jul 2026 15:29:09 -0600 Subject: [PATCH 12/29] Homepage: demo terminals become alternating side-by-side rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three How-it-works demos stacked as full-width centered figures ran ~1800px of scroll, with captions as afterthought lines. Each demo is now a flex row — terminal beside its caption as real side prose with a rust kicker (REMEMBERS / RECALLS BY MEANING / STAYS TRUE) — zigzagging left/right. ~40% less scroll, editorial rhythm, and the side text keeps a row alive during the animations' brief between-scene gaps. Narrow viewports wrap back to the stacked layout (flex-wrap, no media queries needed inside the template). Goes live with the next develop→main merge. --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index 69bf9980..86b76889 100644 --- a/docs/index.html +++ b/docs/index.html @@ -197,7 +197,7 @@

    Link — local memory for AI agents (PyPI: link-mcp)

    From b100df26e55745afd12d4b2cf8a10e3a7f258db4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Fri, 17 Jul 2026 15:31:56 -0600 Subject: [PATCH 13/29] PyPI readme: surface the benchmark results instead of one buried link --- mcp_package/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From 047f46a8635a90e2f0de8045341e9af991f0006c Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 12:15:40 -0600 Subject: [PATCH 14/29] README: pepy downloads badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1db993ac..e791699d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ CI MCP Registry PyPI + PyPI Downloads

    From 62fad282b9694c704aab594d9204df500e6e34df Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 12:26:42 -0600 Subject: [PATCH 15/29] =?UTF-8?q?LinkBar:=20Memory=20Palette=20=E2=80=94?= =?UTF-8?q?=20global-hotkey=20recall/remember=20from=20any=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ambient feature: LinkBar's memory is no longer trapped behind the menu-bar icon. Press ⌥⌘M anywhere and a floating panel opens over whatever you're in. - Type to recall — results stream as you type (debounced), Enter copies the top hit to the clipboard, Esc closes. Confidence chips and the honest "don't know" abstention carry over from core recall. - Prefix "+" to remember — "+ staging moved to port 4000" saves a review-gated proposal without leaving your editor. Secret-refusal and duplicate/conflict feedback shown inline. - Carbon RegisterEventHotKey needs no permissions (unlike global event monitors that require Input Monitoring), so it works in the unsigned/ ad-hoc build. Non-activating NSPanel floats over full-screen apps and all Spaces. Same lnk --json backend — no new surface. This turns LinkBar from a menu-bar inbox into a system capability: Spotlight, but for your agent memory. --- apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 14 +- apps/LinkBar/Sources/LinkBar/LinkStore.swift | 25 +++ apps/LinkBar/Sources/LinkBar/Palette.swift | 101 ++++++++++ .../LinkBar/Sources/LinkBar/PaletteView.swift | 181 ++++++++++++++++++ 4 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 apps/LinkBar/Sources/LinkBar/Palette.swift create mode 100644 apps/LinkBar/Sources/LinkBar/PaletteView.swift diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift index 549b780c..6b92a3e0 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -15,7 +15,10 @@ struct LinkBarApp: App { MenuBarExtra { PopoverView() .environmentObject(store) - .onAppear { store.start() } + .onAppear { + store.start() + PaletteController.shared.install(store: store) + } } label: { HStack(spacing: 3) { if let icon = Self.menuIcon { @@ -52,12 +55,15 @@ struct LinkBarApp: App { ) } 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: PopoverView() - .environmentObject(store) - .background(Color(nsColor: .windowBackgroundColor)) + 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), diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index e2cde090..8f507fd0 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -269,6 +269,31 @@ final class LinkStore: ObservableObject { 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 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() + } + } +} From 57ecb5165edc40016881bc9e880ee851bfa945c2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 12:34:51 -0600 Subject: [PATCH 16/29] =?UTF-8?q?LinkBar:=20live=20agent=20pulse=20?= =?UTF-8?q?=E2=80=94=20see=20memory=20being=20made=20in=20real=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second ambient feature. LinkBar now detects live agent sessions (a Claude Code project whose newest transcript was written in the last 5 minutes) and shows the pulse: - A breathing green dot + "N agents active · project names" row atop the inbox, with the last memory activity's relative time. - The menu-bar icon gains a quiet green dot when agents are working and nothing needs review — memory being made, no action required. Priority order in the label: pending count > active pulse > amber health warning. - Zero polling loops: computed during the existing refresh pass from transcript mtimes (they stream continuously while a session runs — verified live). Codex/Cursor roots can join the same scan later. Verified via snapshot against the real workspace: the pulse correctly detected the very session that wrote this commit. --- .../Sources/LinkBar/DesignSystem.swift | 19 +++++++++++ apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 6 ++++ apps/LinkBar/Sources/LinkBar/LinkStore.swift | 32 +++++++++++++++++++ apps/LinkBar/Sources/LinkBar/Models.swift | 9 ++++++ .../LinkBar/Sources/LinkBar/PopoverView.swift | 30 +++++++++++++++++ 5 files changed, 96 insertions(+) diff --git a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift index 370b634e..e0ed7ff7 100644 --- a/apps/LinkBar/Sources/LinkBar/DesignSystem.swift +++ b/apps/LinkBar/Sources/LinkBar/DesignSystem.swift @@ -172,3 +172,22 @@ struct StatusRow: View { .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 index 6b92a3e0..811d07dd 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -29,6 +29,12 @@ struct LinkBarApp: App { 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 diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index 8f507fd0..b5ad1cde 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -31,6 +31,7 @@ final class LinkStore: ObservableObject { @Published var semantic: SemanticStatus? @Published var claudeHooksWired: Bool? @Published var viewerRunning = false + @Published var activeSessions: [AgentSession] = [] private var lastHealthAt = Date.distantPast var pendingCount: Int { @@ -118,7 +119,9 @@ final class LinkStore: ObservableObject { 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() await MainActor.run { + self.activeSessions = sessions if inbox == nil && captures == nil { self.lastError = "Could not reach lnk — is Link installed? (brew install gowtham0992/link/link)" } else { @@ -170,6 +173,35 @@ final class LinkStore: ObservableObject { } } + /// 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). diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift index 815d608a..cdfb4f0a 100644 --- a/apps/LinkBar/Sources/LinkBar/Models.swift +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -259,3 +259,12 @@ struct SurfaceHealth: Identifiable { 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)) } +} diff --git a/apps/LinkBar/Sources/LinkBar/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index 9f023231..c0e9aad5 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -87,6 +87,9 @@ struct PopoverView: View { private var inboxTab: some View { VStack(alignment: .leading, spacing: LinkBrand.betweenSections) { + if !store.activeSessions.isEmpty { + pulseRow + } if let warning = store.runtimeWarning { runtimeBanner(warning) } @@ -109,6 +112,33 @@ struct PopoverView: View { .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: status tab (health of every Link surface) private var statusTab: some View { From b20ba8ef00f6f33a1a10d8bcf2f24f5cb010effa Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 13:44:45 -0600 Subject: [PATCH 17/29] =?UTF-8?q?LinkBar:=20capture=20notifications=20?= =?UTF-8?q?=E2=80=94=20built,=20gated=20on=20real=20authorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third v2.0 feature: when a session capture lands, post a native banner with the "Will save:" proposal preview and an Accept action, so the review gate meets you the instant memory is proposed. Full UNUserNotificationCenter wiring: capture category, Accept/Review actions, accept-by-path from the banner, present-while-frontmost. FINDING (the reason this ships dormant): tested notification authorization on the installed ad-hoc-signed bundle, launched properly via LaunchServices, on macOS 26.5.2 — result: granted=false status=denied "Notifications are not allowed for this application" macOS ties notification permission to a stable code signature; an unsigned/ad-hoc app is denied outright and can't even prompt. So the feature is gated on the real authorization result: on the current free unsigned build it stays completely inert (no dead API calls, no console spam), and it lights up automatically the day LinkBar ships signed + notarized — which is now the single concrete thing the $99 Apple Developer enrollment buys. Everything else in v2.0 (palette, pulse, browser) works free. --- apps/LinkBar/Sources/LinkBar/LinkBarApp.swift | 20 ++- apps/LinkBar/Sources/LinkBar/LinkStore.swift | 9 ++ .../Sources/LinkBar/Notifications.swift | 126 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/LinkBar/Sources/LinkBar/Notifications.swift diff --git a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift index 811d07dd..f9d26712 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkBarApp.swift @@ -18,6 +18,7 @@ struct LinkBarApp: App { .onAppear { store.start() PaletteController.shared.install(store: store) + NotificationManager.shared.install(store: store) } } label: { HStack(spacing: 3) { @@ -44,7 +45,7 @@ struct LinkBarApp: App { .frame(width: 6, height: 6) } } - .onAppear { Self.snapshotIfRequested(store: store) } + .onAppear { Self.snapshotIfRequested(store: store); Self.notifyTestIfRequested() } } .menuBarExtraStyle(.window) } @@ -52,6 +53,23 @@ struct LinkBarApp: App { /// 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 } diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index b5ad1cde..7ba5da17 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -139,6 +139,9 @@ final class LinkStore: ObservableObject { } 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 @@ -365,6 +368,12 @@ final class LinkStore: ObservableObject { act(["accept-capture", capture.path, LinkCLI.workspace, "--index", "\(index)"]) } + /// 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"]) } 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)") + } + } + } +} From 2ad9dd586a99b94c8fe513fefa7ecd3f5b9158d3 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 14:53:55 -0600 Subject: [PATCH 18/29] =?UTF-8?q?LinkBar:=20Memory=20browser=20=E2=80=94?= =?UTF-8?q?=20every=20memory=20file,=20searchable=20and=20filterable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth v2.0 feature. New Memory tab between Inbox and Status: - Reads wiki/memories/*.md directly (the local-first move: the browser shows your actual files, no intermediary), parsing only the frontmatter the rows display; refreshed with the normal refresh pass and the existing directory watchers. - Search-as-you-filter, type chips (preference/decision/note/project/ procedure/fact), and an archived toggle; superseded and needs-review states surface inline, supersede lineage on hover. - Row actions: copy the claim, reveal in Finder (double-click too), archive/restore via the CLI. Snapshot-verified against the real workspace (6 active memories render with correct types, scopes, projects, and dates). --- apps/LinkBar/Sources/LinkBar/LinkStore.swift | 12 ++ apps/LinkBar/Sources/LinkBar/Models.swift | 70 +++++++++ .../LinkBar/Sources/LinkBar/PopoverView.swift | 134 +++++++++++++++++- 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/apps/LinkBar/Sources/LinkBar/LinkStore.swift b/apps/LinkBar/Sources/LinkBar/LinkStore.swift index 7ba5da17..0f78ca83 100644 --- a/apps/LinkBar/Sources/LinkBar/LinkStore.swift +++ b/apps/LinkBar/Sources/LinkBar/LinkStore.swift @@ -32,6 +32,7 @@ final class LinkStore: ObservableObject { @Published var claudeHooksWired: Bool? @Published var viewerRunning = false @Published var activeSessions: [AgentSession] = [] + @Published var memories: [MemoryPage] = [] private var lastHealthAt = Date.distantPast var pendingCount: Int { @@ -120,8 +121,10 @@ final class LinkStore: ObservableObject { 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 { @@ -368,6 +371,15 @@ final class LinkStore: ObservableObject { 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"]) diff --git a/apps/LinkBar/Sources/LinkBar/Models.swift b/apps/LinkBar/Sources/LinkBar/Models.swift index cdfb4f0a..4c27402b 100644 --- a/apps/LinkBar/Sources/LinkBar/Models.swift +++ b/apps/LinkBar/Sources/LinkBar/Models.swift @@ -268,3 +268,73 @@ struct AgentSession: Identifiable { 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/PopoverView.swift b/apps/LinkBar/Sources/LinkBar/PopoverView.swift index c0e9aad5..f8f0893f 100644 --- a/apps/LinkBar/Sources/LinkBar/PopoverView.swift +++ b/apps/LinkBar/Sources/LinkBar/PopoverView.swift @@ -1,13 +1,17 @@ import SwiftUI struct PopoverView: View { - enum Tab { case inbox, status, settings } + 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 : .inbox + ProcessInfo.processInfo.environment["LINKBAR_TAB"] == "status" ? .status + : ProcessInfo.processInfo.environment["LINKBAR_TAB"] == "memory" ? .memory : .inbox private var isFullyIdle: Bool { (store.inbox?.items.isEmpty ?? true) @@ -29,6 +33,7 @@ struct PopoverView: View { Group { switch tab { case .inbox: inboxTab + case .memory: memoryTab case .status: statusTab case .settings: SettingsPane(done: { tab = .inbox }) @@ -49,6 +54,7 @@ struct PopoverView: View { 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() @@ -139,6 +145,130 @@ struct PopoverView: View { 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 { From b2bc870d17f93d04bf30cfcdf667136c480ec398 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 18 Jul 2026 14:59:30 -0600 Subject: [PATCH 19/29] Homepage: version banner, per-agent onboard picker, origin story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three liveliness upgrades (pattern-inspired, not copied): - Version banner above the header: "New · Link 1.7.0 is out — secret gate, capture provenance, ranked proposals →" linking to the GitHub release. One line that says the project is alive. - The agent picker now pays its rent: each agent tab swaps in the real one-command setup (lnk onboard --agent --hooks --write for hook-capable agents, --write otherwise) instead of the legacy bash integrations//install.sh scripts that predate 1.7's self-provisioning onboard. - New § ORIGIN section between Benchmarks and Setup: "It started with a Karpathy gist." — the LLM-wiki post, what Link grew from it, and the honest kinship line ("Other branches of the pattern graph your code. Link remembers you."), linking the original gist. Verified rendering locally (banner, origin, and live per-agent command swap all present; legacy install.sh commands gone; JSON template valid). --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index 86b76889..18a2c9dc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -197,7 +197,7 @@

    Link — local memory for AI agents (PyPI: link-mcp)

    From dd9049613fea0e1b8fbebe4a0bfa868ffce695e0 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 21:47:08 -0600 Subject: [PATCH 20/29] Restore LinkBar .build gitignore at app and root level --- .gitignore | 3 +++ apps/LinkBar/.gitignore | 1 + 2 files changed, 4 insertions(+) create mode 100644 apps/LinkBar/.gitignore 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/apps/LinkBar/.gitignore b/apps/LinkBar/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/apps/LinkBar/.gitignore @@ -0,0 +1 @@ +.build/ From 47d8d470f077a618bf0ae893595f14372aae3a0f Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 21:50:37 -0600 Subject: [PATCH 21/29] Fix semantic status count: index embeds all memories, so denominator is total not active (was the 16-of-6 nonsense) --- link.py | 11 ++++++----- tests/test_semantic_core.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) 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/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) From 6a1f85154fbffa3d07e2281bd33c5cb7f61ac8f4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 21:55:00 -0600 Subject: [PATCH 22/29] 2.0 story: LinkBar in CHANGELOG + README with screenshots --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 21 +++++++++++++++++++++ docs/assets/linkbar-inbox.png | Bin 0 -> 62324 bytes docs/assets/linkbar-status.png | Bin 0 -> 31380 bytes 4 files changed, 38 insertions(+) create mode 100644 docs/assets/linkbar-inbox.png create mode 100644 docs/assets/linkbar-status.png diff --git a/CHANGELOG.md b/CHANGELOG.md index aea7a176..c9cd68f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ## [Unreleased] +### 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 e791699d..4cc7bd1d 100644 --- a/README.md +++ b/README.md @@ -211,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/docs/assets/linkbar-inbox.png b/docs/assets/linkbar-inbox.png new file mode 100644 index 0000000000000000000000000000000000000000..e15d0c0ae2bab815d140fa2802c43918f1157355 GIT binary patch literal 62324 zcmd43bypnC_r(hY8C-+AyN2NIo=MeTmSq2q}5D5wj3RO;4QWXjc+6M{>#vKF?e3H_?p9p-Q zU?m~(K~6$~{DYIdxs{C>6cl}&Z>*An_(zNovLOul@+`_f-#(_bi`%U)Gc!6lR^-Gi zU40>hIMy$JcY6%^?k9eUZZl3fk0AD8tjMeybun>q+cMvGj5x&8V|4fz-y1zKg~A*% z`XoKN7GAvN#d$P@3`F)pC<>IJ(xPCi-_+kmAxr!25SQlk=@!40XX}b_cwa^w;w#l;kjXe1R8qAk>C!S^8qt?H~Z{FxmuRz;1eO8ZVdF%v6m)A^7&Rj_eiV=7Xf`SgUf`SEJ zK?8q;z#niEvS9x26KJ2TH~;q<#{K1>$L<(86qG2GoTS(%59q^mxMmIY>l?hKr6mT6 zD5OB+EJJq$w;f(C#_$ThfDA|ZXHk^yEHNGN{JbWCWA9evo34h?3&EQPmBo8q zt%%VrK8wusww6)*l9p@X9h|{)NktTBiFOQjSTrwKB(h7EYIs7JkgpVmAbJ>S$a{>} zgS?U$?42T#{PRDpK={B>%+{#?{RR^@@)SKTy4L)^9|hZ+yqpRpKmrz;>OmqewAx8#vsvDSmXOTydEcNwIo)(Hkpbb|!`(qy%4ho)b$B#lkMjA)mkDmmmrsg! zJ(wP-Tk&H`dv7aWnfExrz@^x581u0_di!r0ok?Hx-vs%chHw5m>F)A-6Zu$L(jq{$jsCYZ_g;`sGIf8PAm&`d6rC_IP(inEvpi=Vo_Yf{&&^Fh?&_(6iF# z`SB)@lHt>zSi&5-Uk-un0t3hl2CdCLkhRc3Z5c9O7oCV<=gtD|w~U|0q@gXV#T;&y z+>!kok|?FZEoDd)aYgMo${``5jhO9AcT4UoVN^cH0=i}OYHBRQSj!$8s0=#Qe=d)g zKJQNz7j#E1;0{6NEHaaj*_uCmnJP;9FmB*?n@{Ze@8d+4*qih)pU112^WEH-5GU-T zyoXlb`=XAY9|Bq>>3e!2@QULJIE;h0!(bFrsHGqmt&ifgZD+r1bSpm$&RCI?u(m1_ z-UxH-HTpfA^_@>?8X)@l5c<#Pxeq+vtgs>DGLMxXlr|{~-yd?NzPJ7P1rov5sueBr z1Q~N0pRf1)^>(7o-=Cr-44B8%(d|2#{;Yb9E^#5KM1w%zx6Irxrl+@vjr5N~WkMQjfPn|1XKt&aP2*aX5 zgZic2jVVJ+angEUy68MA1GbHnSd^p7`0=9yaj%Ps{)g5?$!DHI?W*54%guZm z)jwuFP?xnmHJ-mcNTeSCuBkBJe_RT$M=|ZNCz!>D`JSCZP`k5_E%b!6(>>75e>DIh zIKb_s<%Tf~+YMN8MN-gbWX{7JtX`Y$oxzCb^VVep-X|>uZ{9g|puO}qAM~%xz<{-W zH83$=xRlBqORLSHlWSBkH;v;1tKS>++-Pl?oZuKEkNv`e?e7T4;~u#2m~Px_POT5; zLsLrJ&fAJK4@0a?iXSZZ5`z)Rc$9xgBBMv7S>huzfqqtaUF|o*VM}4)K)H(RVgt`i zSs|EMj)+P-P2x1#^5F{8hUCaq*3R^xPhhQ&cb9v)tZ$j`i0qp=;<=x^!FIYdeo`1= zv0cztF0ARqIkdh6-#UImTVsLjDy&Nzq!&_Lw)sqBPiqx@u8pu*1exvFZGbs&4va8D z&<4M|{MY^w1OKPn9hr%z9g*iS;)MWU=H;OU0PCU<{h^P{ALmKyd$6clg=1hz#$z)_Tk1igTrhd%nzh!&o!pe*tMtHSy%U`dNd%F zPa)`Jr3Nn}5UofVmqqez^GZ>=K1i-PXTAUMN+o@EAhz8_$ty z3<`OmaG5>{6eo6juE>bp3%vi%9i-LSXiAAZR&lRL|%X_^$?7SOWMUp>~fpXoi0{43;WQhljx9&SuWS`&sNVgSi6%nEzzOGjxl{rwuJY{>p(xusDilg28QJcd@#zdKoNT2hq-@iXKTtW!+r z>SNN=P1`d|S@U@~{F?p2Y(=ikLM;oYZP)YlY7lx+X0&V&0=kCCV?m}LA;rUjU6WD> z3IXLaq))`|q&oluB&iG(aP_bux(fL~FK&z*Xf8%xK!uI5HMw*b(){L|N*Y7EgN@h^tq&b%h zC&S7x6kDEUZb@l;-Wb>KohkFntjVEl*LMONFII{LfE_gJL#cvhjs%~9pkh?yu*CE= z{5Z!62zKAwv|=e?e|?VicoctP#P)7kLn!wP3e|?980z@## z(jwX{s4pRnEFo~BJc??R*Pw(RAB4Y#7wK22^BUg7z5`AqCt*qO5(80uV8@UhIEbsN zydEqq`D_|F`u5{_6i!snHJo3ctQr3l8hgs#fMh-<2wtDMG$&D@>=* zc3_LludkzGR3{r8(cAMR5}2}GI*NLptFlli)+m{{t~hL#>X(ne3!uF5#IiT_Zzf6O zxmA&fakpJyBtzjDom6maq?Q~m__ zt{47i(`@f$kI(LP zxl)otI_K@-I8A*|Dd`we79g++dWrPHp}X6FMR$9!G|$%=n^w6k$@#$K~)liJc)1LyZ z`q22VU7=)t^fDx_iHpF7=rk&H^XJ<$5MI)wU@UcqVQ&Od^Ehq{viHO}eX$0w2ikVA zLyntHOc!cxf1<2EUt~TrPZg<0Bt_{7fH3>#=;Au1(fwCh8T4zxI1C!%$w~xtQ=e;6 zYIpQ0-X3sACRSQLpr3b9-FSu@*S$M*Y_1l#acZd@T7codohmS!dv_9YP3lrK)mq%B z{+`W%QRLyquW%Zib5bcflAmt^lo8uHbJQJ*?&L7 zXyxIs;{*{Y>n4*-l_^8^s_7>Frc6-%J+n@h)uR7q5|MwaLdwF5c9ppta0T4j$pxan z6;tC`ko%2x$FljA*fdLjUG9!kVUa0B6Qv2(+AT2xyJPc9RNNa)!t@ehKRw>dm}A6l zLC!YJCVwk^;m&j#WRU+u{{y)Ddp0I(o!^|ck~9Z_!K9lp@Na2U|1)e|)=JTlzcy3h|8=+Zs7k|1J-R&?fO7E%q!rsOFopsmNF`QuUMvDqNCoYn$GV^ zKS(WsDZALIk|p+e1{|L+5p8c&916EYRcVlU(-<@|xl14?IbrGTCyX=98pUj#xc6T_HE8p+&&S}mp zMXp4rCecAQNHp7v>sozR&&=St6}IU= zZ1jHC(f9+WDO=S_9|(Ut@QCMbxku0M(2)qDx^A*% ztEY}zG1QMkW)N&A^ak#Y+0lHBym72Z@;jcw1VE=CC9|rzKK)io``v0Ajrkp;a(nw> zJI#I*5N;Y1$Nvfwn$LQP2od}_rumYIoPgVjoJ@vG5@^s2$aSw_|4VaZ7Kd*N6(tUUJDtg`cGEMI#`2jUwhKuXOyk=M(D1L%+=;sAed< z-;PSH2I~oe?0(6krpaZtd9&u^-t`ZwEIWO?Olh8A(Nf1iDMe&-jDvoo_Y zS5PiTlf%Zl@$l_z2Gk$W$S00D_m3k^SNcQ$VBnw5tTGLX@V1?=juQ)lw<6x}_S6Sa zqd{lKIo*R7+STC2!zS`YT4A zKl2oTQ#sD`=}$ZVh>w5Gl%^qgCiV zkFjq(xZiP@4*T7tFTYU`Xh~;(OZ4unmfI^rLBErw__~2Xw%&D$li^eK_rxMq_0tnJ zVj=O-y0Xtq5q&Xw;@%9D{nP*v$>$ASwP6xvLh^CoJbC)5bHO#jr(v0-_+qx zvmPh7$boBf%GJhq_@+p5p(Vavq2mZcRvAs*(F2iBdWr1+%|S(U;*W!V(aKPPBmtuV3b=RKr(kbjF}sk|97h<$+hn+y@e(_`Is_diBYA?e;DL zcal8=dDrn^r5Pcws6THe^K9|mXYmMYr(wD5wL@wr+||T7{fR3mT@@16Mh)DD)0$FD zSZwJbCVaLz=kHNRF7+nl{%xW@iyl~&3Hjj$Uz zl2!uAf~@H0uAR*5lPV%p1X#$)|AKrb8Te|rH8K$vdKIJ3f+}511Tz{pV7k?>_lwK+ zbN+v6q=iOzmWg~~9*@FE#h}v$AYqcefvG&1Ft=>DgTmT}Jl!Ea5 zpn8AGdOjIiW{siYRn=N>a2Ch&tSv2an>Bp5R01sx4Rha$RH0g-^-6Q{LJh=hqALu__*+%5^nWY<#rSw>t^vv?(zuuoJ=zs zoPT^A*eh$aA7w)lvK5+(-W|Ju5=ooh6+n6?gVGw~tF5w;dlwsMN%CN~$b!Gbby^Z6E4;xSms>lq~4D`<6Kf1IIr?*?72Mn_+7A8Rbv z>;*COoxp7?Hr~M0$XUmwGts_gwRhVS4R&924Ap626;Mak|#*T`B|UBmZP$5)YMK#tx-~tSaTiR<#E-hFcY4> zhmmPZ=v{z<{_v@4*g^JB&CcL9+auFf)F*nj@2z zzP-Ncv*zd>9%D79h7nv~Hy_4V2iG^wQuJOB7!7h!5g8^8hKP0skLur~J2vZ6e6|W* zl{>54!t$#Tr|tSyMy$X)pJPWmv$UTq0ILP=c^jM-_RT9HFyiGmLGm_X!+A5braC;Q zTTHcf^E+rImZnEtR_vR``;xJ z#?NKGlWrooZl-`bN(~a72v$*ZI5{<=6TXhy@)ec5x!bNc<8l_2ZKPeUm0NmT6%y0! zUO-g^yAV_zCcsZ|z9_9vRIyl{Dz5p=BL7pZ|EOZi(M7J*D~MkOB|Gdy7@#-#$7`Bj z-}jRPdXwu97nt!?-Q{5edebGx+LP!-QpSn_deh>Ag_ioO{FJ2w^yZ%A%=!Bl>zq93 zMK5QKlo!3q&sb&H@VmD2gpojI`~NllAk|LVJZSJx;_~{zISRD%-X_66jo4R>d>aAu z<7wSK|G%azfTni-|9nt%{L|YuI_t8-k@=&?qqr9D8~NlE$K?@oZ%Py<^j~*)iUm+5O`{G ze?&6gwI^n*DJ5F3S?s${u5?4)4C%#xIG$pK-*X1w!jf(8+Pesz%i zOr>L&^zCL>%$EOX8};OGWpozK z^Z*f``xa*HX5Kx@hug#X8U?qbLC+3>J#k-xyvE@uA|4NU>+rJR*--jlMiRm?Bm<>{ zFt^{K;J5w_sF?z?x^lK@hi|!RN7UdJ2ro?m6?>~Sxf!lJn~$x}eAr^&S#h#vh}r*m z;;jE&GPBLrYO`5oidFvZo;x}7Q7XW8va}?TNs~2x*}vTI3Fg*#mwohZh4rt_yM)$9 z<%zzyo#70s6*Mx6CLt%=32-V7o4#VSP-~1~D%#Vgns$S6(mT6(EoI^SO!FJSXjWMA zay45nyU6n#+ICtW@HJcf;mB$J4?mUHFXxiKHpS&|kwNuc`0iwkaGW}mrrZ_b)YfIy zNGzw>kmR{RtIfB6MQRGld`F*DJ}M0*tNhjBHk+Hs|2cC|*8JP`k7`XgWAZqa%n;vB zCKGnAQf$~pufiD?P4#jVAy0TB$$=qQbEU?5X3!oPx9HnMuBM9BLTh?~p_}xe3qIsP zt5MMkddR+&ko9w&OcUmf5t@>Sg%F8PbNZxCEs)){(SP1K($(n1C>p) z_1C9AT4J~?y3{ny*0%FCaahZ~<#tE>sXXo(hw}|;MH*$aN@?6v54|xYCub7cbB(TP z?<{9U`}`x4S#YGxvm@K&lbCwGeF5Iq=zB2sMv)eqy=73B@8dN)xxx{ZVk;B(o9RB` zQ)@Hudy?WSkgqA4XqIx0>hrDf+2dr;Xs%CW)srvMD5jliz6-6PE0w1uV93X9U5MJ~ zGg-9{k|^FqcR$nfO39L&r0q0#7a*$4yC>PO9b(tlsM@ z)>1UdJ*fu%`%7p05APDK?HiRrLBp8LA7rlvt>HniGQvwShsO*Nv=b-QPjmelkO;qf zCAqWS1v@PGU6+`DaigIqN%OAz{a1C1 zey-b(>Ol4BD&dhYU`;pMyrEKu&h$Tn^j>O^&>@?i=lcC~*TZf(=bHrz z@p0UZ{XDz*5WbOKqkGbzI-_?;M{7lJJUx)&3ptssP(v-STYl6m`Xo2{N2hSZ|9V*4 zE5>bqelG3S{`5FuOCBO1C=uAq@3K$+mPtL4I|Eu=&+TNHg`Of&Mo@Kv>N;NE^Fl~D z^}Siqzf%;h&UzldYZO@=#Xi>bYCEIb5`#AVpWODpZL@Sys6EVj&fhxKYw{#wl#=iF z8fbZUo-4U($s>w@og!+>K*-6KYuRzRux=vzpqR1u#oo{kJl?H!z)C>G<(emDx;Tvv zl~H}ZyD0k-p!oAN7i;a8cGVRj8^f2SZFYtOZ6tbFeum`gb9mQVq%E_FZaID?2esy& zHWplXMqI=yR1imUH9uJ0bW9>oYx-hmB;{)PYsbEJrDcW9nNLV;vBCHIku>}JAj-bb z8r$3;;vTgoJ6j+@?YT*aVC32@FXPBVa*p6sX&K1<~P{j1li6WDb z+Zt5$7W~$nkEmH2-6*t1Nb&wnzU*g3G!j^RrdVp~2-lIv?)r|{pv`0!z0w=9kT=rb z{dF}jmCIji&-5=eLVJpApSiALy(-8j^=_$OoTl~}RbTa)%TW!NR>K?1J zC$P72r_q3ZlECuG!99{EtKK5Y{uD%9xw8G2*b>f|@lFXU8ARgCeIow9Odl(Q-FU~taan#F zHc@M#d`}ZfWL1;q=2K(Ef$tP`o~+K1im=oIKZp3=)+pcNE7IdlB#8L4l1<#lbe)?t zhN(F0#nbTrDF1+OnlCibGrw9k!_jiNnAxhDFTXroP$)w0G#s@aY;x|5RTAxea~!rR zv*^3$=FPL0@x~561<%&4@`P8xA)kY)J=`Ru3)R?Zmwc7%#HcS)Br@B$C)|r#OPugd zH_6Y~&1#8l4+(Gih3o`jVi!8>=F6y&nH!~Pi^BijLN0%Zh<~K&UNctFPd=Q#vL@*ng z{hs?Pp4-We*PAY%JjSC_;f+(*Cp!4qHncB5uqbOjH-GCm4+>KEk?DRlk9R`Gfo1Z? zuRT3OI+{icWtcoL5LZUqWp7%_0<)XSpIN`o8&2e5mrUAf!t&Ok!mK#?d~3&`$aR$O zNf}Hz%?erAZ;%g%O~kN0JXEsnf}Jqqk0>LxZk3LDMB>pQxuQK|CIgefZ!p{6=~ij5 zw0PoQGHHVUosJ6LV5u!zRt{G!{`6Lxq4Fl(s>uc`SA;3tJKo0D4P-|^adl8;WPi<8 zt1#{XS?vUaxLlcSnN4~zsvzgfHk67*UQf;*&`cu3?kf7RULQ0(xQxt4Hqkk_-gKN& z2yZo3YS$qnoe``k{p7x4zT0mk75gQh#5lxwvfLrn>@iN6?uwC-c8wvDRJo$4AXj>C zZ;5NHU2kTD+hGwygfK)}>ArT4XA>rM=YTq>-X=xWZ$*dxHE9rSr!_y2MGf`Mv%3glBP*&D_&=B)SLCK!~ z^Qf9EQ%gDuf;>e{3WAH=l&*v|fma}mfPN>M+p;i!p}>5t)u zJNTwi(Bd}}G6cL{A{ak+v~V;51ER)W*2aHGu;z~ zdt7?&A2xKzH0!rMMho9GkR1iJ0dp)BJ*#yfx^5LCjdEKiUQtPr&4)l^vnH`Qx3?5`<%e~1 z*N2SaA9xK_IV#2{X)PbCwTmJ8xVAv{iJ+VW1?Ezr*;M7HB?&Px#?wQPYj{$ONcuCc z#?zC>lZhVl1&+3$Ro8w$Dn7OMc?|9`+X4wT53_w~pBh_A%p++th?*Y_|1)2{nOL%v z0r*0aTIXrpquoH7orQ>8i|g0kUt-yntdBvUxdhgXYsiIrUqz%P9^JfsC+^d4z0amA zp(yCX{Ic8Kr+18$MIcIx`%;=okb#Dq<%`B>7mNDJ!xQh+gXY50Gr1d#XKP|5*uS81 z`Uy7P-Tx^ZbF_as<~zx&^1~nr&{`xCnrdZ2#11DvQmi|mUHCYP%2+lr znB{?TxeMJ#Zs|1-35ZsEjIYjX%aSx_iTYtQ$le0tP7X)ZIvfgs{CrWo8}z3kZK7zt2{@N|!pB{P#2=H7AleO1t zBnGtq8ks4{m%kjZ8k-PM+~cto4*%&(&KJ6^60K(YwXYbcfL6Ec*Q$T*9RdOHRRrx& z9p_iA{vDv={x92&5)gy}d^HFxQNs7yilhXPk0#{Awfpa00bhL*o6epler+X(!Z4T& zl>nLNm5(C_zWPjl@WDFzwG|-U+8GlP`R(=2IRHXF*gw|w4|m_IF0}+vKVUlhhiGeS zR{~k7xfbs_hr_1_$HV_*YY=pR__t=2#D^!%YRiud8sHxpc)_(akI*=>egFa{e-4-3 zp&|3y2h}jBO@m5Pxqt^Qy?9EWem@|wmbxrmocY=bE14`H(?Ote2TTOV1;e@j6V0*# zr!Lfr5Xd2g?+XM0*dfp#g@f_9w6Way*G4#AN3%AqqBW5OnSY^CMr$L4B zH=D)!kNiMR4*&*}0O(8sNM+`m4JC5|>1ti=JCt}1lU_^jHUMriU9jCl$AT@_UGcqH z3d-X?z0S-sBDCyVZ_Pi^Bt+Pt75+KJxBJQ7VK5rV)=fs6XW|wciPJEe zAjv7JuthEM{7_YGIllf?-5Qsoe8S3SuF}kTP`b?j;x7-U)dcn4WI>E#hM@M{^%0RA z50Iwz1Tt+~EjP=rdC?cpQV^EIVuU!A+dBCLf+I``cm~KjxfoKR%7DA;1$%1k{J}I^}phf>X0I0nW`Mf?52O%EX6Kj22`bgH!KpGdPNvGz^9s~l}h=EgF z^e|Zjf38!ej}X>x_NZW#!`T{4V#)_{=5d@15s+aJ{Y7q!@Wc}3B7!o0mHU!w%Gfc0 zR!ZUDqzkxfJml0Y+l>A(E@&fX5HNHT8`QI6yO`QT+2;vNJZGCiM z*PoBMls9DxX_{@PpWhiRm@{|oSn0_M*}$)!D{hA^SL;9&#mJZ0J6f>ItV1qe^3lsE zbeTStFAN2LKghyR1hN_=kCme96sALrF7m*hQi*63+X>?>zK5oQ zc(`H50isYHMP(jzYMs-74R{ay=q+WV+2;&6oKDUrDlgx$^*2kj?0xhxP^F3ILvxt)8moT+U_tmBUMC^k-nd~uk*}~FX?Nl}k?4`o=ik4v^>+5h zQPr%-M&$cVD)AKNbke7Cn1o=x@5%bmkGBD9cHZ{8KHirWg4zAsAzD$f8QcSXAC^;^>NJ~ij2|XZ6!fyB#tWzUD)Na-dC4AmbmBg&u zYAOGHQroLODNvjc9;DF;kP)SuD~+z!g+MYL?GAY4XpRV+2r9vIm_#jk9h7v~h>td8 zH!$Q3aL?%GhJ%6Jj1w^KgGY5MrEka%(yyJU#84m>6?PvI!6v$1d-?LDi>tDNx9kgR zy#-G%tgH)jj!BP+Hl?9s7b?##$Fo!y4OWMM=(rdoA7V24;||`U^WyHv)>yG7Tf=Tn zgbQw{RD@(#IRNbxB~rXM_XBl10+0RTb9ko28(_8lnSr3w`1O&v@ABEg^WZQ{+q{b; z!1n8~KbjZ!{xL2Vc(#h1akK1QW*%C>ZZ?S1aJ!v$^@}6=D#~zG*P#Q>X z7d407KFTf{@I{pv3ga7Q`kaT=KL0R@9v`ZhCpjPtqv(5?dvV^I0Q@@jo}+322T|uZ zk|+|h4PpTr{=F(s*Kx@zxV%!0C7jpeGAcrnXG{-mN2tqo2HI36^s8{{$38&lw7ByJ zfb&AQ;J5#Z)R}755oH#mI@?jGyfuY+;BjGic&NpeF$e}jh=vhwv7=o{VnpRDU}sR) z`BNEK0^jZjTXFqdQqlb-Q5k)@F%X*i@%DU+%2ee^%L~|moTc_~1f14Mg4fpDMk=Z% zg4Q~mf1lKIKESLQ*6;^l07yj+^&v=v4n82ZkjdfIM2D^y{}BMB zR)fPs9I4Mv5a|8yVDnY{v&R5(*y}GHozF*cUv7YZyTU+u?C;qNMkD6-AB2_uaw~08N^epmg5GzD}G7vFG!SLxlLy`};n-ESx4@d4HR_b&>?A;3K z`5X7$At5L6u~WP)Gx1?&olkiGPwpqr1Oy^@a#cFNW^zns?{zWAIQxqs;Tz)`!SQwB z4IUy7qqLOWnXBiWKY{Z<0-DZ4iZWQZ??hPfMm8K99cF|I#1u4~wd|!o3fZZ;YVeDf z{?XAX;`8`rmz}|z#={~K*##p%!u;-FcPAl^kK0t;`M}A85scEo-ecNukY+Qoc*BGld5XV3zUD zeuTxdR41xQvelhMKBK*yH7s17ICii}FElQntMOU%6LP7iaU8*9eBLzF~J` zq`zaPbv+w9F1d9ilx&FhXFGQDxODEfy`WN5uopF^54Taje?3mcR|4nNb0E@?z{?CH zoZgOQb|ML0Aq|3`p+p|2=15+6d-wx2_Z_yCK=>^H1QwSDRg8sqe{ly}=Sf+q*`Ib2 zeU11VWs93bU;;v(sF%U43(2DMJk8ull8iudj@h9t16JYN>;T2wCby%aC!-x5qp#$A zal#~_aNF+1vC-c?;HG|{$6ymSgl9I=k{0WbL2MA+^|zJ8Zb6< zpd!~Yi0yfUE|~Q+ZOYG;ylwt7Jicp1L4Y^pyPj_iXWKI#6WJmH^LGGu5e5I-h!#+G zr1BiQbGU7EdZ@3r9(Ym}M*eYFnyq}f0u)`O|2@JfMoDb`{KgPW9*MBCj#Qe-e*EDV zP%=<)yq?2+_l^gDL%f?NEQW-Phn0F!kZ(<4DaYurwBgBQvEpF&1$$`Z9LgJ!)k?b( zCw}f>%;dOwK_*W$QSi-2=&LNP9FmM&fR=n6YlOgF_;z)RP7%jQT{2|FOBJPYJc1qI zV8?D&GMqM|rh03F&miO`d;t(Vc@>HUv!x;7a}ZU|T3Ggm`wnbz$E<9KNk7}#&00aH zZ-6WMt>M|E_5g?^B)?B7{*NIXJG~Y%DEt7_PeET_xcj}_x}$y|!=$BL@7-K2a&dI-?CDYHv2S)oVj7AQ zLL#ce2>uF3s+1EabhgpMtI}&ApE??LQ9W94bm3g?O<3dfn_mTXoTm<4!#~^3kfS(n8b)P0j$5hkx%xFZBg zgEwNX0W*==A49}n>k>oA^V{Dnrv!I)yG~XTM&n?z;K>m{%ddWrCdUkfl?` z7=FLqBBzF4m9B)L2n?WyZ!C;tUNt@dp+f_rKX6Rp|1|Yiz!5+at!DmDqk>*g z@?iZ+{{Pa^Fo3n729;xG^fC;hVxsM!HuB5(ssHe59C83|hrwV$e(gUJ4B#}tWv76n zUmMDU0-C}3YN`EdN_@Zo90E2_i>WK1n*qNC4rrzTN^+?4)gcIc>4VrVO>yq)#WDcR zh`^ES;J%&+2Nvi9vN)SI$*Wf)3k@{G05x9uqT?m9$bmi>V0aDezZy)UNI)|x(BG54 zy`BgTIv^)Z!wCRhIn9TE$ets;Wt6Ql{~K&z3V>Oh*3;mAS2RNIFVuhrN`4_?HA-}7 zfCPTD?lVA3o1Lxq5Ngyr7!h!PN&NTcqr}hmwndJ-I|~iUknUNIK4Pk3&9Y=bj>`d6 za{0OqPL*~``~U(P?{YA$1k|$-@tpw7AW&p8P80KRH{@vhWu`R!Q?Z8923WgF8bE<5 zg}g3f0kZ()xa@Vn!cq+Q#j3v*39Ocz8$Oll^2O6-dgny}1zxd0Wl_~-Jc&lG#3WOeGLd_zWm!7uY2T->~ zKP>C4iGrU1f_pHQLgJGcraNF4%#%(uUHbx>_8g$wFsT6{qL#m&7zsb`|KFs+f8}0c z05-VjTLjI+KQ$FZ*a+97&E|^{HC4BrhuSYw@`#vDrRv4WSUcfwneyycTAwTti0kZ@ zbd-75f^F7@>B~zgMuAF5nM7EmEI@))T8>i!)ogB`I!BY|PUx64OA~=0jo;i?S>(~h z_U7UCVyk9GAA5p5_Xi+qUgVw=;Ik-1;Ih1v>3;-t`PiM&S$Iu`{=6apYZ(jx+0l9BbhFqxa#aN(K{`JRSs~eOTS`6PQvpt-e+XmpFKc1xT zV^RZ$kUL!hhhU9Ysw`q&Tpx!OQ3&9T5sq9fg2Ig#>mB76$0r^Eef*^+2A@NGf4YPT zAaE!DC?qp~;9-t9di|z^FMDe9Yk%r<_~x@fg&U+ z2*MBKj%y)n{DDKAx8nbbj@qiv0=*<}@6w;tz5F zL7sBEzmoAzJ*>fzY*AsehS_W!3 zU9;=!&z|CUQY|tFxcRMdhk0i^g0`aL#eS@O4d2eOS>L1P6071k~J7yjVt0YvyOj8Z5Y+!fp?DVve3o z4z>J^&5$0MlkVF+lC=$eaozd|ylW!nz8<59&2bsBBju{c7r6XCs98|xj!vUwMkb8X<}{v&@F z#DQv_pZD5aQmH!c?=cMhg}4Z=PyRDcV^?1AD|d9Q+t=l}iTN^u=YKLZB43}cA)@iX zaXQ2-npamiE9>Q9{K0E*|F!=5|HXp<*gT3?1Z36qKAcu~y>3koW8|@y3!KhDuxy$e zJw^iV$N$iSgIh%Ccy6_I|Fd}mAegfE#cw_A1r1`>;mn$E(#v$t_)YbNd%ZV3BdE(L z-~Z41m1Tpma^E|Jmlam+piYNoBp$P!vz%TbbkJ4tbMswH%TeJ7Fui7*j z>467Oxq+ZdYi~}`JkD80qcP{RHRQ{}Tz264Ck3zAptF3fX6~f|=afhZA{^-5;y%k8 znarAc+KewTyvWc;?Tjfauy%pwBP_m-XGeMSE{`+F6%%Yia{5de`5Xq!=_{lid-fRSC>uO0jkiB1{ zT5mSVWHU~89^t!VG#QBzIOff!>ht~kfBy?0I*&@2Ou{I+bEcYH|GZOuYV3Xjck8U< z+-YmDfw3QO70Ql`q5{#IF8m&VZFslofr04Y#W%fsgc647N~!dS;xxO^3wWs!Zu`-5 zxI6vX{w=fkp!v3#-@lYmo?4qZ)i#`vrbzpP^K`vdhC^Zb4Pj`4LM>Lb5B<#xZ9olr z5tQf6^NV3aW{tRxEbP;t?)C{FIYb6W`V1hV0)we+zT?{mWo^Q$Z)R|Nd)e9^a>!cg z(2mxJtb9JAtec{oAp}ey_^fz4{1pl~aHh=0NUUGro3MoHbWDslS(P&R|{UNqJY$a92~t(dMh10YSO zd+HqV*fA9G0OUCiG}wWe2p+`4C>h8eTVMEK&^3X~@f5Uq%Z^Bfg^0w%=P91J zxVs4D(+^9zWwIW9DAyLo4Dg7L!&<##hA0$1bc9l2 zj*`oXN=?Kg_^gIbz~;Ftb)Nb&T{BUNezo6Mjzg5HQImohTF&*#j%3 zppC3@S1CmM(_BDTE|0-mVK6SdlK`7BRP3}hs{$!a|0%aB(kc>Z+!NSmnBB}Cb`4ik zUeC%*xP07rpRe0Xjmv$MeNu?cJ8lkmM0hM}emlBzDr~+`&{n8JNgO zPPx$ynEUIXo~a10M*cWFo-ZHB)j{0zWE#H={@^9^UHw1go%LUoTe!uQ4(XQeZl$F= z6_xJp1}W+84oOK7q`SK%m6A?r7`pEs&%J-e{qcNw56q13ynF5Sto7YNG!)yJC-4}( zv9vdhx3_0Ib1QD^jXut&>AyMbDEJ=J3LTGBh*M(r3odrXtB6q_9dzUwu)|P=C>t!M zKo^X(hrv?VA(m=fuA2i{K_V-_T_#ed(K?cx7#H(K^!A*{_q;(r&oH739{rqm1GmRt zB_U5y*O#lNNC?tvflQ3?Sbe~EhAGRuHmy^}QHH5QaGjc!Gme!2(7a}hS%zhXhqJ!OnTpaNm{Px;FVX1Y>Zi9#J zF~L^|c6U=Zq^JU$l@Z=;aCrBZxr)+l47c~xM{BezniCZ?mqP({;_26q~h z%v(e80%4Fyowkv0OPt&osR}`Yq@%YxiPO(I>{PP7vG1iP0`bGx>m*i$7pBG^Fk_ep z*G36ly02x{(oFDSEOl!OOT52jhy}%xT%koeURzTN+lk`aIy1j`Mc?>w(T} z@gtiT9YFB^tF$R*4Rru9F}M*is2ZKGO2fq;jYGtj(xnsWBUltENCux%lFbM|eh3l} zIqk*48uR(En)jF0%q4Na>`P8mC0jt>m|Dkb zw?5a6nU)rQkfxA%;Pa#K`veL2oHk$z|$|5Vom@4stbLtO|2nGtB^;)Q>d46q$GS_YZ^vl6VJ zRsvc~O5{JOOB4$%ivCaWA1D4@`wMD)yd+^I`1c~X$bi=obxc*7`|sKk01py*&8Nlo zPc)kd0mFi5MM_QV-?gfN;1^jr)av}(6%4wA*T>x+Sr+i${ucrKqF<{PP5(@jxKr@@ z%)JRT;s4veput!^71>&SxBTzNZ#l5$TD($Oa)jmZRR|eNw@2g9A_<1al!#4Z364Iu zIQ!)x;Ua^03a#75l!Fr*(@Y;wLRy@GOLde8+&brZnQZ5Oc^`BOCz zTYyv)19eiM`(sd7kNFkMS5TtY*8IwPMltbVsYP8nity_V*pp40CLVqQYbwYaC{i8+ zKn=K+L93Q(7Fxl!Z@WmDd2R#p5-hA*wQ=BuTW5lL{SX*| zQ=cx00m4Y3VmcQLG*N2el7z+5f*BFF~U_I^bEG^{1lkl+WS-Jo?Z>P_}nmZboi%0_7r<+HzK1zZ#G*1 zCyjUAIsI~sIlIB!zFr_sKe|VN#&nmy)uIK&O`xS}6Y$>T3FEGSMe$8ioE7>l4}#fk z=^zz!vw)?zgMt9m%5@k4R4NJ7xYa-(Fi>`bVkcJkR+eH57$QS=#~l?OO-SG$A{p2| zP4A(&kXF#q{w`WAlrslxae7!p^be-%RPoy&8}U9^l+YuVgkN=kb^Y4+of-5Q5myeV zP+VZRXv;3&$^Of=D*&F$^W4I>GU4^`Gg`P3bRsUveWY+|fJac(d255&(1S!J-RTP1 z9P&JPl#pw5OT@S+wtm%HJI^PrPn5ZSO=0G0`p7ucP1S@Rf-YDdY0;cttBW#PS&Bcp>MQyxZwjEjzDZKHWFFH(rSQ=*o)u;3##rbS5||c?Vtz_EuOmu z^Qph1nL0+qk~q1!GjEoxEi# z%mD_;a(_H`xa5!ZQ3udW-@5o}1LsOef5vH-HE8ym@vj#>%=iX-q7_ySXlvXPv|iK| z{i(F@_x4e7$xpgJ%RD(X1;LUn7|spP&bGHdv|gh*`&bRSgH3XLe?B2aJAfh#^Y3I* z10PoP3U$U5K+iVIYA%(GD|3g)MR>~NK)zuFibk`Bg9a!>ODM`mR{}4*fpd5C!S|i& zD9K<2s5(R6ZCaZs#K)iEAs{34A^`ZmvcRzUeTYNjt@9p}Ddo+hYmzJzygH#g{wJb+ zoKd>_fELtL^nh+-o)8`H(>?)eiRXDqmayDxNmWn-2SB|N$Ynz5mYY1LpRZ}0BNfD| zbQnEIPu&b_7cAYtnv5ns2~h|?pM+pUlOT38Du6<*?k3(o|50nivaqjJrpjrmTT~n zd%E)ry$Brh7w0Fc?+P;QzZ`+d%f@h?8oZdRLL6W&hG*2rWyT8Q0h*;$9=U#D8*ouf zYGn+cZ_g*^72k$8x_9X>1a-TJXLM#dbO0-jzj~~Th1or;Z!;I$zzzQBSNJ}&TG_-r zey@GdvCM1?n*TXfrN@mhd+!nHAJesOYt;@el)Db6b*fc%J+-)*fE<%TZTEu3U3Q*z zP~1-W#PSK`wc(fL~_8S0F7^W-gO-VCNrKH6aHpc5~)>a~A0uxi!4b#En*_ zX$D_lI)h0EzHEEX%VQ0Mk`VWCSG>=nxBC>g!9F&e1+Hc;V$o$<$q7K%N-A=k@hctz zN+U$vDEY7Ao;+)fajD9g@LeoXFZ>FimL)4PO6+9~>|gM-jXe*H3@RPCc-|W`p{^o) zQ&8#Sx1NXbmI2JhJ?5d4p3SIdN9pdSXWzG~-Ihr{KPxrus42X=@SV^VWHvwENUijC zO7!LU3{X{S)Y|tHVuLXHJlZG^LA~NsEsmMc*vX#i?qq%_c1=sjF(I}a8xH;}$B#k} z=hI)34ugub+`f!R`#xC1_lCX*MJN3}{JgKrh;<*fhS{^pgH0ny6!Accj zouQAQX~f)(-4HQTM?1>>p!J=h=3;-g^bbwL;n^(fr18S$34G)S6z%sz#PDGlE6}Gx zf{dSSdPVjWt4p!+uf;biQt97+n>O&?Emt{y{$(NL>NRh^f$j4fyjSu%Gq2j! zIa%w=)72$L`v=ae#~hloh~Qseq8RP7P5;x0T@nB__7SNB7oreyP!g$mU2=dm^55`{ z9Oe@8ik$F&!_QqYer;qo2FHfh|K(MQ!u)PdxGUe6$^WPQpl~RcTq}?NgsVSP`xz8# zB>(3gBLWe*EBYJqpKyhRirh}+Dkh?TeOz=PBB%YoZ~bSxB2WQs>Yruwk?Y@Zf)pxp zVLy&c|9d+xB!M=C73}*CZ4-xx3P43J;`fx|f5H{KA}k*Sw}_8J|E_g`id^&$tSbMB zTuku#UL4?-Cj7g07doUP{dNo4#EF5XD@HST#|f-Lo5Tz*59T3YLE62Fa84Xs2k%DI z4Gt1?@&rlkkOk@1Es(t!*7N)_c8z}6=?`lP9=XO9c_CZA;gilv5xIO?Sq!JhaO3%#aFD{cAx!oQWI&Bxjz$ilMA7 zSiOb1CUQO@!3C&BHfuMIQ)QamH;;G6TVUZBk6o7-=`}*g`CY0WsyANSNo?0B2J)JS z&=FDgM_VZI5@=Lplni4WiB$v9T#II6f7g28%|gB4*2@L!fZrdgjHFoI#xnUKz=cDB z0~|{F677a^#S9*$p0G;5c<}%s#4P~xNxHq*9|3?P&{@0ukOzfYY}QUICqf|yTc5x+ zBT%rzjUM>c-hx&0_)%vx?I4r}XxWq=%eRiy^lGgn-C2{ZJN8_`~*#9Ll5fp)?~GF_TVXWhNlSK<6J8(70ydm@J_P zAYH1%L$k9ZKsZt{{DJ~VwSe>w_;N754unfGs3nk80hSD2xy{0utMENCd^kF27;!f*1#KE zVDYQ^i!QL&SZJ`Xz&`@s(iYe*&w4sf>v$d3K?A{UP5hyYl!`%?=fEqb2Jbcz zit+^W#i9cK&$ljZJV4@l0C?zYd%IR>4w1{K7l}6Vy)wVlnFScvW;%2udmcPtNIhH2 z0qNfFlFPnNpFKcy^A!rskUp@C3qBn$zf^9E)7U*~zq9)xNP}`CI>^9|i{h$VV-}sz z_fcK9FesSo8#KatbPI&(e zP#z%xyI$F6q}?LQ|bcew93SGmPLU=Qp@;#VH zlVWU!KHq!++yBgt8i`D7@~!YgkbjpuFZhUqMYUMHbr3XiFcDyhnUgZoh@m3iiqvq@ zu+CTsr`==-+m@I&DC2n?I=0Amnz6}TrD4GDNDk{xr|teu`2kN*>aAc3d%Ajh%pn0^ zM=MinlNH~jcc2l_hDGUCW-}}U9g@Ep*pu?PJLJ6qOvGREvB`E|j&J42YrF74SzwnY zdRz(Jk?*S#=IddE1ZjLW9R@HSjAtSzz>o?lT&}m(rnO7btI%s(+!|y@iEc#N;xqm7 zo_|#--9Vl{&f-;Bi9~I^6w^AXsOq_;a<^i*&kLN}gF))p59@{NR}GUL!goO|g;*83x-jNUrE5ls*bg|108@lK9uYtBqo&|AiPltc8q#8iAJ| z+6?jqAMxNF-#t?{b~s4h+r(#DB3()QxDS zO)hM$$Crccjyy?BCDKR<$vOr4w1vjNG|!?F!-{)Qqu~@lw4#N)>Vhv7X3*%Y#T$9q z<=JnV!qf%dQ}!ZZJJz`0BoG0u7HLsQ`Fj&jwrjUFj2gG+LkDH{Uxk4XojyS`SCg@aWhoH-qe({I4u}2x^sp zzR*CUe1P~LXncYOS((9Tk*omh?!`Gg%g4b2M$Ad0vo3iD+bwfbBN1&(A5`@|f=B#_ zP-OHaW)z~14k1Uu*Vs%a=iS3uOj%t2hi&;heo46jKeKm-(7wR^SH6*qFQz=O~;{KRd2MxrPUJ_YjXm~1nxcg6@|T>;%!3PSSF3ehcVv|r3-DfAez zq-`|pG{2wNjCIOwkNPwN584zsay#rRK)DBDo)jfoV%cN89;s=rZtcooeS*@T8qJeU zJ=#ahKq!C_pp1`(JwZ*QMhG$vS!9mC&hE?EI&7c3-rPFuAB5azX=ZPIHw61{O`7rs zq%T{HF@sGFx#?R=A2hR7_^`Lpg-nyAydj(8e@YUz;#>4C@ndUR`_@^GrKhcvj&FJ| zw8~tvu}6Sulu<nmm)7`bfRW> zl+ps8bXwY^{*-}lJ>zAzAWJ}C@n&5)nWeR5_LK0)dxVz=+u5jWs;DZbW*&kC=iZ>@ zp_#4CJo5flGuvq#5*1YjL;*WCZw0!JZ!?6DAj-F&%N8>8wz{>?;FG5BQraNC1wC{G zk4yDg{0r5Pqy`VPvIE6VW9=_fb6#wC$x~<_X^3mAiZL^57sTNd_1yF4Sa1vj`{6ZG z=_>|}sQV^cm&g68sVWMYFBYcF-X|C}90c_of7c7vdDZx@L(SCrw~Yex*jgljJU@MRvUme!#dtkyhdmdS{F7KYOwk!NB3k@4El`i9*)t$=8uXm{97d{R#?1e zW$WJ<9Xgy#j`=f;nHKT2yfFK!d1$mAI^Xz(s$|LmC} zf`Lc#uRZM9`HjGBKK4O~!r@ZpEcb=I`R1YDLHy3zOFjEl-xGR#zk(eQoxybd*e%pP5Gs2;#>l=wrO_4@cUYPmE-ycw zw{VSw?yHdx%a|g^@&4bMhTaL3EB^{cjH58ZMS+tgv9p1!ABHMZnCV@Ogep)FWv3OO zw>AV5e@Xpz>bY|^Ca5A3*Xvi-o?lpXrJ12<-*dT-p+yxA}2fy+-^`XWMxvPv8 zVI63th;5#B!+ohQDhM3fnyv7>uVmgT&lXGU<4$ho&zdcxH=5)Eo4*et|b}u9(wIY6BTLYG|cq76; zhQ|I5vN_dF;ZR9aMK{zqTU0FA zFr#KO@&6dc@!szw04Ch zKqba3hmV8D*Rq>#BFk?g7ZmmjGC5Oh%g^LH3~3BLhql!}+wkXnIm4z#jj-)|D`U&%0-F^TMT42gL{#< z_~0?6qOsd3>2MYFlkhso6!^gB0>h|1{_dEpyA=BU_%P4&Pu@;F9y4C=LA^0pO=f3v1jf7E)r!_8~hm62myKVHcgStaAONf1%IctrQddx4nG};x`w* zW9K0IQ>4%JrujNE-~D7k>ixoHZ-=&s@ z{?jKJ@-q+FTcZ27z1QtGy_9@+c$~(uoT6zN7dK}pWG(Gc$>il?NXuIT#hmjVeTc`% z8#q}$;ji)UPq}vaW)xWy_tGtN|GoEsEpBFm2jsxGDmcqCyf;@5e+}+6cEXd$nMI!J zV6NCm2mM4oO-~YzVSSq3lZ$!^U<)UFhtnRkGNGCAs7N8?yYw(SAaYlRpSuX`P_@uh`X(zvIs2&;uRra~SndYFGJyo(G(C9o`|omV5=O07jLYg>^jOB$bf}HvL<603Qr7f zF24iErl21LCXp8O^O%FI^M;cUB9*Y{EL9K2MjTD{`~wRFagEPTRMU$F4!}D9_9u>r z?nsMwq`+>hv#WDs#AftlBRO-A&|bL9UHV!~@?a5@F9>T8ZT>mVAANG~6S1Wx-0>Zm z_sFc)eBN3~JG@RcbZI#0+xW2cTae7?jNMbzp|dNf_mrVD*PKN>R1(uPG(yh%BeMQ6 z;9sjAT^J4wtBPirwY}z9cY@5--Lvx>8t|R_@YMev(UnV~n%8uKkzOe`*ZP?^Qnx4Z z&T=u|YxbFrx%ALRBKUm~n}?tFVy9JPgRoHtm+M0=ET3mSb4h_wbs>EjS%@egj3Q!r z3}@~rdwTVv-w2+zzyHzF<1QMrk0Es8rx$@gCjES-wH2hT<5xhFau3^(HDpQcpmv?n zn&(+AA!dVXYAF0XW3qm?C-#28ujC8zM+(n|#EA|^>W;m?!%D4?lmoYz2}*zX%W(ek$Oyy#K}s2XIc{pc92ToO(gAsBZjb{tW3);QF~=3BrMri zXm(;WsfRuA*At|&^0qJ0oNnM|#w++4FWLHlK;k}0somovb@mgy>p|B*a9q^&07Zpr zC;PsyAM0{!mFfd~&d6cp)jBIZ`^H-HvlTES&_5=wT>p*FuvWRGJ=v|BHE%X@_Tv8Z zCHmEUlQNRVD$Bhh^*zvc#a$X2NW7F7eJ4p&S;LQ}R~-1W9tQCH zeuzQ$3z$ahVx+I=Rn)30(nIy#zimI&d?IsD#hoYj-K3y+C-zQ`iUAt~4o~yL2VCw| zN-_$wweyMqC|b!UN&ZxlAKUna5B2_y@f)m-&l6h_1C*3%M&| zU#wd+rrfS5uILY+h1i_uFHVvaH+eViASsaeyqBTL+;~Gk-$CBA(&Woo)3`NoFtYme z=4#RSNzIVV~sO8der~LrFbv! zY#YN}7$V>4U+oV6xG~RfVskxLqf;>uulLeE(Qj3e=b5C+&%j~DdS4kDistOh48Nbk zHXZsluXq>fB_QsBn?*(eVdw~cZQXH+;p9O>WW0(dbANe6#L6tt_%Jr4A)XPNLJJ5c z=*YLh&5k%Vy8$i?1PXP32`Q)qSQI6YwnE^V;`95cg?f>j7D2(8hYUwdmc`LlbcH~V zXkJG;bO3=mXpIj8J18REzm`&NZ`g#Wl!8Rl5eFw59o0TLE=M1nNrF3iA`()Q{N#>VDuCgZP3j!zZe((_{Nt^+Z=+-v&op+oc-(h6X_k<;>Tg;ia9KJM+z-S zfo*F(eR-kkTh)gkjHjxrV6Q)=y+zeJYY_P_A;;rUufN5;Y0;fL&ocP4(dADH3O}?A8jAXXZro=HahIs~`HBLAAeVII3aqdP0g&Q;FldRYj#or*#3peyPMl zmKR$Se1oaV5#bDEFtt<_`l+&~gRr)k6;YcQ!Ut-u*sSHK1cykwCt~h{6P~*il=C3v z^5+b*QTzM0YjeoV5y7ycxwv-V66o7Y*fDqa!M_i~Z9u`KC%g&f$>%QaaZx^S@6>6z z>+yQ>A}we_pZ0pQTuaFs-c5%eGzYL?h4=XM!VTL+|e!#1Er6crEzWa#HGt+ z*>v&eEV!&4e$ybO7N-ucR82_EyO&p#f(LDoPFM`^K^a2izmaG0L?GmU5MT+4iMY)V z+9Z%8T5HM5KKcv)3?tOSQmhE97WU7sGHNND=rN`F3}MlWh>+3|NyIn$o!H2``y6+4 z^hkCMx{)zhVTd?-T{`$7Iq|4P43LZRfoR;=#utvdoRzwT1& zE<`ZCwkzy*GG5Awb!~k5;S(Qx^3Py}t@yj>JGO>ih0EnvfnzOqqrvdhGLWOcSe`M@c~r9>IbH3Pe49ky|>$NFv`jmZBJ+|BO!6 za5-Ff&7d)NW^rXP_JVq{zLNj$VGT1r^vjR27wnY2A;Fnvs6m_LUv7FfH$@isXoB%^ zRP0p{gEf)Pkj*M((TDH1#3go~JJRfzpOGsuCiqQe@;i&OzkaSSlNrD67|o@sCe0I- z_qTu}3l4cM7oJm~=RTlJz^RcRJLC3^lY-Ib8K(sO6oD^^X-$MXU`t(DQYFMiw$|0{gD7WA=j3MT`|`yD zk<+Pzgz&RHTJKp{iNP_CG!eAN7KgQRDS?5wEK`#>ZK3W(xblG0$>&%`D^VZ+{-RLr zF!*AG%{nL~McRch>R{3@NsP_xpA9-oDRGS45}waRmjp(Xn^B$fF0mQ(c&U6aNhfkK*z8bkU`ah)~%=<_nSE_{r5?)d(| z-vHAWw9sQMFURl|Ha=a@Z}j8_i`mihOiCf9cveP&SjfGyj?r`4v7_rJR+J#Y5fXZk zYf-3!VZd0Qwwbimzm}jIuU5cmd)?!4_m0-$jk8D}br?#r!L z)WL&u4-(tj9lW8VXNajn6gW(_>z$q9LWRe+@LCp?V8B94$lapwMFn>9V(r(j^V5u9 zvvz_GN)&`lRNO~V;+HvzQRfqtsrY)ew->4IBuA9)-NAj`+mk{rd8reN=heNPEGkfo z7>WM3(RsQkXHUQp!qg9F#fwV6p3MNhw}PM-M`;cB1y3u6l)*n@MrNMdGTyOS(E!y zh>0f&@1aJA8V{@70X+V)H_ubMWPOF^)*ITEPd16ix672;>ZUhG<8G&&S<-0ooXVs>na-$vwr{-AM@zM|?>Kmq3|XmK{Q?oU z42kHZj1iqe&ROzoo-wed8n zNS{$BnSE9{fD-KA}6G4SGMcizW0LgmY`izRbpf7Ga(EyQ|!D6GXk z?y5zfW&@033WRPuu7)Pii6FbIS*A-B&NlAD5A%mho6!m?iEF%WNoXCWZNz>X^hG^h zH4j872Q{>Ij|WQ^q29}-EoD!x<*m-Yo2qZF`gjJ%(5$x-v$Jf3-|LZ^jN59}4;Ex> zsrwAg9dEUj6KE_-Uvvom(wy_#w!Ru8h5R5|YxS7lEjl0=hA?g%(1!AE5qWfY425

    k&w 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 literal 0 HcmV?d00001 diff --git a/docs/assets/linkbar-status.png b/docs/assets/linkbar-status.png new file mode 100644 index 0000000000000000000000000000000000000000..92fe07ac38afe28e095c401d8890d8eaa7ba555d GIT binary patch literal 31380 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 Date: Mon, 27 Jul 2026 21:56:14 -0600 Subject: [PATCH 23/29] LinkBar release mechanics: bundle.sh --release-zip, cask template, 2.0 checklist; homepage LinkBar section --- apps/LinkBar/Scripts/bundle.sh | 11 +++++++++++ docs/index.html | 2 +- packaging/RELEASE-linkbar.md | 19 +++++++++++++++++++ packaging/linkbar.rb | 27 +++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packaging/RELEASE-linkbar.md create mode 100644 packaging/linkbar.rb diff --git a/apps/LinkBar/Scripts/bundle.sh b/apps/LinkBar/Scripts/bundle.sh index af5d528e..a7c6e729 100755 --- a/apps/LinkBar/Scripts/bundle.sh +++ b/apps/LinkBar/Scripts/bundle.sh @@ -29,6 +29,17 @@ 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 diff --git a/docs/index.html b/docs/index.html index 18a2c9dc..e9a8f080 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/packaging/RELEASE-linkbar.md b/packaging/RELEASE-linkbar.md new file mode 100644 index 00000000..85ec43cb --- /dev/null +++ b/packaging/RELEASE-linkbar.md @@ -0,0 +1,19 @@ +# 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) +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 From ad1fe486f447a318cdeeb5cd73274ec95ed608a4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:16:28 -0600 Subject: [PATCH 24/29] prepare_release also bumps the homepage release banner The banner advertises a download and links the release tag, so a stale version points visitors at a tag that does not exist yet. It now moves with the other 5 version files instead of relying on someone remembering it at release time. --- scripts/prepare_release.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 2d7ed236..f1f0652c 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) @@ -182,6 +199,7 @@ def prepare_release(root: Path, version: str, release_date: str, dry_run: bool = files.core_version: update_core_version(files.core_version.read_text(encoding="utf-8"), version), 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), + 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] From 0d67d693ae9ac1fc5be25beab1c4a49ef8d5d61d Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:17:44 -0600 Subject: [PATCH 25/29] Fix: homepage banner update must not require docs/index.html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made the release script hard-fail on minimal trees (its own tests build a fake repo without docs/). The homepage is presentation, not a package artifact — update it when present, skip it otherwise. --- scripts/prepare_release.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index f1f0652c..6249de55 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -199,8 +199,13 @@ def prepare_release(root: Path, version: str, release_date: str, dry_run: bool = files.core_version: update_core_version(files.core_version.read_text(encoding="utf-8"), version), 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), - files.homepage: update_homepage(files.homepage.read_text(encoding="utf-8"), version), } + # 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: From b63dc9ca3279ec10f826831c1b7a34fe8fb84b72 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:33:34 -0600 Subject: [PATCH 26/29] getting-started: add LinkBar as step 10, renumber semantic/FAQ --- docs/getting-started.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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.

    From 1bb11ef35cc25d85e8a1d8dae0c1a1804d007849 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:41:43 -0600 Subject: [PATCH 27/29] Release checklist: rewrite formula caveats to surface LinkBar and drop the obsolete manual-venv MCP steps --- packaging/RELEASE-linkbar.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packaging/RELEASE-linkbar.md b/packaging/RELEASE-linkbar.md index 85ec43cb..000e4468 100644 --- a/packaging/RELEASE-linkbar.md +++ b/packaging/RELEASE-linkbar.md @@ -4,7 +4,31 @@ 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) +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`, From c6db2ba0af5a89319936401e0f3b96da4056c6f6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:46:12 -0600 Subject: [PATCH 28/29] Release 2.0.0: version stamps, changelog, homepage banner --- CHANGELOG.md | 12 +++++++++++- docs/index.html | 2 +- mcp_package/link_core/version.py | 2 +- mcp_package/link_mcp/__init__.py | 2 +- mcp_package/pyproject.toml | 2 +- mcp_package/server.json | 4 ++-- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cd68f1..552f79a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ 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: @@ -22,7 +33,6 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - `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/docs/index.html b/docs/index.html index e9a8f080..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/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" } From da58c42d0c605b5e0027208c955646e521e1f1c6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 27 Jul 2026 22:49:35 -0600 Subject: [PATCH 29/29] Lower type ratchet baseline 387 -> 385 The 1.7 hardening and 2.0 work removed two type errors; locking the lower number in means they can't quietly come back. --- scripts/check_type_ratchet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: