From ae111f07bfcc9fee5693a3c152cc5ea15a5f9c38 Mon Sep 17 00:00:00 2001 From: Mohamed Hesham Farouk Amen Date: Sun, 26 Apr 2026 13:58:14 +0300 Subject: [PATCH 1/3] feat: buddy dialogue flavor, draggable notch sections, usage cache fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buddi-hook.py: track tool usage, prompt count, and denial rate per session in ~/.buddi-session-stats.json. Compute a dialogue_flavor field (chaotic/runner/explorer/methodical/neutral) sent on every event — purely additive, does not touch buddy identity at all. Follows the direction suggested in #5 for personality-driven dialogue. - NotchHomeView.swift: make the three notch sections (music, buddy, calendar) draggable. Drag any section left or right to reorder while hovering in the notch. Order is persisted to UserDefaults so it survives restarts. No Xcode required to understand the intent — contributed for maintainer review/implementation if preferred. - UsageService.swift: fix usage display lag. Cache was loaded and shown indefinitely even when stale (>5 min old). Now detects stale cache on startup and schedules an immediate re-fetch instead of waiting another full 300s interval. startPolling() also re-fetches if called while already polling but data is stale. Note: Swift changes submitted without local build verification (no Xcode installed). Logic and API usage are correct to the best of my knowledge but maintainer review for compilation is appreciated. Co-Authored-By: Claude Sonnet 4.6 --- buddi/Buddi/Resources/buddi-hook.py | 88 +++++++++- .../Buddi/Services/Shared/UsageService.swift | 23 ++- buddi/components/Notch/NotchHomeView.swift | 152 ++++++++++++++++-- 3 files changed, 246 insertions(+), 17 deletions(-) diff --git a/buddi/Buddi/Resources/buddi-hook.py b/buddi/Buddi/Resources/buddi-hook.py index a303349..7b0d9b6 100644 --- a/buddi/Buddi/Resources/buddi-hook.py +++ b/buddi/Buddi/Resources/buddi-hook.py @@ -3,11 +3,13 @@ Buddi Hook - Sends session state to Buddi.app via Unix socket - For PermissionRequest: waits for user decision from the app +- Tracks session rhythm for buddy dialogue flavoring """ import json import os import socket import sys +import time SOCKET_PATH = "/tmp/buddi.sock" TIMEOUT_SECONDS = 300 # 5 minutes for permission decisions @@ -80,6 +82,79 @@ def get_cmux_surface(): return None, None +SESSION_STATS_PATH = os.path.expanduser("~/.buddi-session-stats.json") + + +def load_session_stats(): + try: + with open(SESSION_STATS_PATH) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def save_session_stats(stats): + try: + with open(SESSION_STATS_PATH, "w") as f: + json.dump(stats, f) + except OSError: + pass + + +def update_session_stats(session_id, event, tool_name=None, denied=False): + stats = load_session_stats() + s = stats.setdefault(session_id, { + "tool_counts": {}, + "denial_count": 0, + "prompt_count": 0, + "session_start": time.time(), + "last_event_time": time.time(), + }) + + s["last_event_time"] = time.time() + + if event == "PreToolUse" and tool_name: + s["tool_counts"][tool_name] = s["tool_counts"].get(tool_name, 0) + 1 + elif event == "UserPromptSubmit": + s["prompt_count"] = s.get("prompt_count", 0) + 1 + elif denied: + s["denial_count"] = s.get("denial_count", 0) + 1 + + save_session_stats(stats) + return s + + +def compute_dialogue_flavor(stats): + """ + Returns a dialogue flavor string based on session rhythm. + This is purely additive — it never affects buddy identity. + The Swift side uses this to vary what the buddy says, not what it looks like. + """ + tool_counts = stats.get("tool_counts", {}) + denial_count = stats.get("denial_count", 0) + prompt_count = max(stats.get("prompt_count", 1), 1) + total_tools = sum(tool_counts.values()) + + chaos_rate = denial_count / prompt_count + + shell_tools = {"Bash", "computer"} + explore_tools = {"Read", "Grep", "LS", "Glob"} + + shell_uses = sum(tool_counts.get(t, 0) for t in shell_tools) + explore_uses = sum(tool_counts.get(t, 0) for t in explore_tools) + + if chaos_rate > 0.4: + return "chaotic" + elif total_tools > 0 and shell_uses / max(total_tools, 1) > 0.5: + return "runner" + elif total_tools > 0 and explore_uses / max(total_tools, 1) > 0.5: + return "explorer" + elif total_tools > 20 and chaos_rate < 0.1: + return "methodical" + else: + return "neutral" + + def send_event(state): """Send event to app, return response if any""" try: @@ -134,13 +209,17 @@ def main(): # Map events to status if event == "UserPromptSubmit": - # User just sent a message - Claude is now processing + session_stats = update_session_stats(session_id, event) state["status"] = "processing" + state["dialogue_flavor"] = compute_dialogue_flavor(session_stats) elif event == "PreToolUse": + tool_name = data.get("tool_name") + session_stats = update_session_stats(session_id, event, tool_name=tool_name) state["status"] = "running_tool" - state["tool"] = data.get("tool_name") + state["tool"] = tool_name state["tool_input"] = tool_input + state["dialogue_flavor"] = compute_dialogue_flavor(session_stats) # Send tool_use_id to Swift for caching tool_use_id_from_event = data.get("tool_use_id") if tool_use_id_from_event: @@ -158,8 +237,10 @@ def main(): elif event == "PermissionRequest": # This is where we can control the permission state["status"] = "waiting_for_approval" - state["tool"] = data.get("tool_name") + tool_name = data.get("tool_name") + state["tool"] = tool_name state["tool_input"] = tool_input + # Count denials for chaos tracking — updated after response below # tool_use_id lookup handled by Swift-side cache from PreToolUse # Send to app and wait for decision @@ -181,6 +262,7 @@ def main(): sys.exit(0) elif decision == "deny": + update_session_stats(session_id, event, denied=True) # Output JSON to deny output = { "hookSpecificOutput": { diff --git a/buddi/Buddi/Services/Shared/UsageService.swift b/buddi/Buddi/Services/Shared/UsageService.swift index ada2400..be83cdd 100644 --- a/buddi/Buddi/Services/Shared/UsageService.swift +++ b/buddi/Buddi/Services/Shared/UsageService.swift @@ -43,8 +43,14 @@ final class UsageService: ObservableObject { private init() {} func startPolling() { - guard pollTimer == nil else { return } loadCache() + // If already polling, only kick off a fresh fetch if the last one is stale (>5 min old) + guard pollTimer == nil else { + if let cached = loadCachedUsage(), Date().timeIntervalSince(cached.fetchedAt) > baseInterval { + poll() + } + return + } poll() } @@ -60,7 +66,8 @@ final class UsageService: ObservableObject { private func scheduleNextPoll() { pollTimer?.invalidate() - pollTimer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: false) { [weak self] _ in + let interval = max(currentInterval, 1) + pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.poll() } @@ -111,11 +118,19 @@ final class UsageService: ObservableObject { let fetchedAt: Date } + private func loadCachedUsage() -> CachedUsage? { + guard let data = try? Data(contentsOf: Self.cacheURL) else { return nil } + return try? JSONDecoder().decode(CachedUsage.self, from: data) + } + private func loadCache() { - guard let data = try? Data(contentsOf: Self.cacheURL), - let cached = try? JSONDecoder().decode(CachedUsage.self, from: data) else { return } + guard let cached = loadCachedUsage() else { return } usage = cached.usage isAvailable = true + // If cached data is stale, schedule an immediate re-fetch instead of waiting baseInterval + if Date().timeIntervalSince(cached.fetchedAt) > baseInterval { + currentInterval = 0 + } } private func saveCache() { diff --git a/buddi/components/Notch/NotchHomeView.swift b/buddi/components/Notch/NotchHomeView.swift index 1788e76..71fb6c8 100644 --- a/buddi/components/Notch/NotchHomeView.swift +++ b/buddi/components/Notch/NotchHomeView.swift @@ -415,6 +415,12 @@ struct VolumeControlView: View { } } +// MARK: - Section Order + +enum NotchSection: String, CaseIterable, Codable { + case music, buddy, calendar +} + // MARK: - Main View struct NotchHomeView: View { @@ -424,13 +430,16 @@ struct NotchHomeView: View { @ObservedObject var coordinator = BuddiViewCoordinator.shared let albumArtNamespace: Namespace.ID + @State private var sectionOrder: [NotchSection] = Self.loadSectionOrder() + @State private var draggingSection: NotchSection? = nil + @State private var dragOverSection: NotchSection? = nil + var body: some View { Group { if !coordinator.firstLaunch { mainContent } } - // simplified: use a straightforward opacity transition .transition(.opacity) } @@ -440,16 +449,26 @@ struct NotchHomeView: View { private var mainContent: some View { HStack(alignment: .top, spacing: (shouldShowCamera && Defaults[.showCalendar]) ? 10 : 15) { - MusicPlayerView(albumArtNamespace: albumArtNamespace) - - if Defaults[.showCalendar] { - CalendarView() - .frame(width: shouldShowCamera ? 170 : 215) - .onHover { isHovering in - vm.isHoveringCalendar = isHovering + ForEach(sectionOrder, id: \.self) { section in + sectionView(for: section) + .opacity(draggingSection == section ? 0.4 : 1.0) + .overlay( + dragOverSection == section && draggingSection != section + ? RoundedRectangle(cornerRadius: 6) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + : nil + ) + .onDrop(of: [.text], delegate: SectionDropDelegate( + target: section, + order: $sectionOrder, + dragging: $draggingSection, + dragOver: $dragOverSection, + onDrop: saveSectionOrder + )) + .onDrag { + draggingSection = section + return NSItemProvider(object: section.rawValue as NSString) } - .environmentObject(vm) - .transition(.opacity) } if shouldShowCamera { @@ -463,6 +482,119 @@ struct NotchHomeView: View { .transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .top)), removal: .opacity)) .blur(radius: vm.notchState == .closed ? 30 : 0) } + + @ViewBuilder + private func sectionView(for section: NotchSection) -> some View { + switch section { + case .music: + MusicPlayerView(albumArtNamespace: albumArtNamespace) + case .buddy: + BuddyInlineView() + .transition(.opacity) + case .calendar: + if Defaults[.showCalendar] { + CalendarView() + .frame(width: shouldShowCamera ? 170 : 215) + .onHover { isHovering in vm.isHoveringCalendar = isHovering } + .environmentObject(vm) + .transition(.opacity) + } + } + } + + // MARK: - Section order persistence + + private static let sectionOrderKey = "notchSectionOrder" + + private static func loadSectionOrder() -> [NotchSection] { + guard let raw = UserDefaults.standard.array(forKey: sectionOrderKey) as? [String] else { + return [.music, .buddy, .calendar] + } + let decoded = raw.compactMap { NotchSection(rawValue: $0) } + let missing = NotchSection.allCases.filter { !decoded.contains($0) } + return decoded + missing + } + + private func saveSectionOrder() { + UserDefaults.standard.set(sectionOrder.map(\.rawValue), forKey: Self.sectionOrderKey) + } +} + +// MARK: - Drag and Drop Delegate + +struct SectionDropDelegate: DropDelegate { + let target: NotchSection + @Binding var order: [NotchSection] + @Binding var dragging: NotchSection? + @Binding var dragOver: NotchSection? + let onDrop: () -> Void + + func dropEntered(info: DropInfo) { + dragOver = target + guard let dragging, dragging != target, + let from = order.firstIndex(of: dragging), + let to = order.firstIndex(of: target) else { return } + withAnimation(.easeInOut(duration: 0.2)) { + order.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to) + } + } + + func dropExited(info: DropInfo) { + if dragOver == target { dragOver = nil } + } + + func performDrop(info: DropInfo) -> Bool { + dragging = nil + dragOver = nil + onDrop() + return true + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } +} + +// MARK: - Buddy Inline View + +/// Compact buddy widget shown between the music player and calendar in the notch home view. +struct BuddyInlineView: View { + @ObservedObject private var usageService = UsageService.shared + + var body: some View { + VStack(spacing: 3) { + ASCIIFullSpriteView( + animator: BuddyManager.shared.animator, + identity: BuddyManager.shared.effectiveIdentity, + fontSize: 8 + ) + + Text(BuddyManager.shared.effectiveIdentity.name + ?? BuddyManager.shared.effectiveIdentity.species.rawValue.capitalized) + .font(.caption2.weight(.medium).monospaced()) + .foregroundColor(Color(nsColor: BuddyManager.shared.effectiveIdentity.rarity.nsColor).opacity(0.8)) + + if usageService.isAvailable { + if let fh = usageService.usage.fiveHour { + UsageBar( + label: "Session", + percent: fh.utilization / 100, + detail: "\(Int(fh.utilization))%", + color: fh.utilization > 80 ? .red : fh.utilization > 60 ? .yellow : Color(red: 0.35, green: 0.55, blue: 1.0) + ) + } + if let sd = usageService.usage.sevenDay { + UsageBar( + label: "Weekly", + percent: sd.utilization / 100, + detail: "\(Int(sd.utilization))%", + color: sd.utilization > 80 ? .red : sd.utilization > 60 ? .yellow : Color(red: 0.35, green: 0.55, blue: 1.0) + ) + } + } + } + .frame(width: 100) + } } struct MusicSliderView: View { From 85b8620c89e7effd9e93c9db71659ebf68db7cf0 Mon Sep 17 00:00:00 2001 From: Mohamed Hesham Farouk Amen Date: Sun, 26 Apr 2026 14:06:52 +0300 Subject: [PATCH 2/3] fix: address concurrent stats writes, polling loop, and stale drag state - buddi-hook.py: use fcntl.flock for atomic read-modify-write of session stats so concurrent hook invocations don't drop increments; use os.replace (atomic rename) for writes to avoid partial-file reads - UsageService.swift: replace currentInterval=0 with currentInterval=2 to avoid a 1-second polling loop; always restore to baseInterval on any failure/error path so the fast-fetch only fires once on stale cache - NotchHomeView.swift: remove duplicate dropExited, consolidate drag cleanup into a single path with a short delay so cancelled drags (user releases outside a drop target) clear draggingSection instead of leaving the section dimmed indefinitely Co-Authored-By: Claude Sonnet 4.6 --- buddi/Buddi/Resources/buddi-hook.py | 31 +++++++++++++++---- .../Buddi/Services/Shared/UsageService.swift | 7 +++-- buddi/components/Notch/NotchHomeView.swift | 9 ++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/buddi/Buddi/Resources/buddi-hook.py b/buddi/Buddi/Resources/buddi-hook.py index 7b0d9b6..b6e6bdb 100644 --- a/buddi/Buddi/Resources/buddi-hook.py +++ b/buddi/Buddi/Resources/buddi-hook.py @@ -83,6 +83,7 @@ def get_cmux_surface(): SESSION_STATS_PATH = os.path.expanduser("~/.buddi-session-stats.json") +SESSION_STATS_LOCK_PATH = SESSION_STATS_PATH + ".lock" def load_session_stats(): @@ -95,13 +96,31 @@ def load_session_stats(): def save_session_stats(stats): try: - with open(SESSION_STATS_PATH, "w") as f: - json.dump(stats, f) + import tempfile + dir_ = os.path.dirname(SESSION_STATS_PATH) + with tempfile.NamedTemporaryFile("w", dir=dir_, delete=False, suffix=".tmp") as tmp: + json.dump(stats, tmp) + tmp_path = tmp.name + os.replace(tmp_path, SESSION_STATS_PATH) except OSError: pass -def update_session_stats(session_id, event, tool_name=None, denied=False): +def update_session_stats_atomic(session_id, event, tool_name=None, denied=False): + import fcntl + try: + lock = open(SESSION_STATS_LOCK_PATH, "w") + fcntl.flock(lock, fcntl.LOCK_EX) + try: + return update_session_stats_atomic(session_id, event, tool_name=tool_name, denied=denied) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + except OSError: + return update_session_stats_atomic(session_id, event, tool_name=tool_name, denied=denied) + + +def update_session_stats_atomic(session_id, event, tool_name=None, denied=False): stats = load_session_stats() s = stats.setdefault(session_id, { "tool_counts": {}, @@ -209,13 +228,13 @@ def main(): # Map events to status if event == "UserPromptSubmit": - session_stats = update_session_stats(session_id, event) + session_stats = update_session_stats_atomic(session_id, event) state["status"] = "processing" state["dialogue_flavor"] = compute_dialogue_flavor(session_stats) elif event == "PreToolUse": tool_name = data.get("tool_name") - session_stats = update_session_stats(session_id, event, tool_name=tool_name) + session_stats = update_session_stats_atomic(session_id, event, tool_name=tool_name) state["status"] = "running_tool" state["tool"] = tool_name state["tool_input"] = tool_input @@ -262,7 +281,7 @@ def main(): sys.exit(0) elif decision == "deny": - update_session_stats(session_id, event, denied=True) + update_session_stats_atomic(session_id, event, denied=True) # Output JSON to deny output = { "hookSpecificOutput": { diff --git a/buddi/Buddi/Services/Shared/UsageService.swift b/buddi/Buddi/Services/Shared/UsageService.swift index be83cdd..9179639 100644 --- a/buddi/Buddi/Services/Shared/UsageService.swift +++ b/buddi/Buddi/Services/Shared/UsageService.swift @@ -99,6 +99,8 @@ final class UsageService: ObservableObject { if consecutiveFailures > 5 && usage.fiveHour == nil && usage.sevenDay == nil { isAvailable = false } + // Always restore to base interval on any failure path so we never loop fast + currentInterval = baseInterval } scheduleNextPoll() } @@ -127,9 +129,10 @@ final class UsageService: ObservableObject { guard let cached = loadCachedUsage() else { return } usage = cached.usage isAvailable = true - // If cached data is stale, schedule an immediate re-fetch instead of waiting baseInterval + // If cached data is stale, use a short interval so the first poll fires quickly + // without creating a tight loop (minimum is clamped to 1s in scheduleNextPoll) if Date().timeIntervalSince(cached.fetchedAt) > baseInterval { - currentInterval = 0 + currentInterval = 2 } } diff --git a/buddi/components/Notch/NotchHomeView.swift b/buddi/components/Notch/NotchHomeView.swift index 71fb6c8..9ca72ce 100644 --- a/buddi/components/Notch/NotchHomeView.swift +++ b/buddi/components/Notch/NotchHomeView.swift @@ -470,6 +470,11 @@ struct NotchHomeView: View { return NSItemProvider(object: section.rawValue as NSString) } } + .onAppear { + // Clear any stale drag state left from a cancelled drag + draggingSection = nil + dragOverSection = nil + } if shouldShowCamera { CameraPreviewView(webcamManager: webcamManager) @@ -541,6 +546,10 @@ struct SectionDropDelegate: DropDelegate { func dropExited(info: DropInfo) { if dragOver == target { dragOver = nil } + // Clear dragging state if the user cancels by dragging outside all targets + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + if dragOver == nil { dragging = nil } + } } func performDrop(info: DropInfo) -> Bool { From 197a1452795c5272767ec92a6de2ac22b2ebd03e Mon Sep 17 00:00:00 2001 From: Mohamed Hesham Farouk Amen Date: Sun, 26 Apr 2026 20:54:32 +0300 Subject: [PATCH 3/3] fix: address second round of cubic-dev-ai review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buddi-hook.py: previous rename collision left two functions named update_session_stats_atomic — the second silently shadowed the first, bypassing fcntl locking. Split into _update_session_stats_unlocked (the actual logic) and update_session_stats_atomic (the locked wrapper) - UsageService.swift: reset currentInterval to baseInterval in the missing-token branch of poll(), so a logged-out user doesn't get trapped polling every 2s indefinitely after a stale-cache load - NotchHomeView.swift: persist section order inside dropEntered after every reorder, not just in performDrop. Cancelled drags (release outside a target) now still save the new order Co-Authored-By: Claude Sonnet 4.6 --- buddi/Buddi/Resources/buddi-hook.py | 32 ++++++++++--------- .../Buddi/Services/Shared/UsageService.swift | 3 ++ buddi/components/Notch/NotchHomeView.swift | 2 ++ 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/buddi/Buddi/Resources/buddi-hook.py b/buddi/Buddi/Resources/buddi-hook.py index b6e6bdb..b88394e 100644 --- a/buddi/Buddi/Resources/buddi-hook.py +++ b/buddi/Buddi/Resources/buddi-hook.py @@ -106,21 +106,7 @@ def save_session_stats(stats): pass -def update_session_stats_atomic(session_id, event, tool_name=None, denied=False): - import fcntl - try: - lock = open(SESSION_STATS_LOCK_PATH, "w") - fcntl.flock(lock, fcntl.LOCK_EX) - try: - return update_session_stats_atomic(session_id, event, tool_name=tool_name, denied=denied) - finally: - fcntl.flock(lock, fcntl.LOCK_UN) - lock.close() - except OSError: - return update_session_stats_atomic(session_id, event, tool_name=tool_name, denied=denied) - - -def update_session_stats_atomic(session_id, event, tool_name=None, denied=False): +def _update_session_stats_unlocked(session_id, event, tool_name=None, denied=False): stats = load_session_stats() s = stats.setdefault(session_id, { "tool_counts": {}, @@ -143,6 +129,22 @@ def update_session_stats_atomic(session_id, event, tool_name=None, denied=False) return s +def update_session_stats_atomic(session_id, event, tool_name=None, denied=False): + """File-locked variant — safe under concurrent hook invocations.""" + import fcntl + try: + lock = open(SESSION_STATS_LOCK_PATH, "w") + fcntl.flock(lock, fcntl.LOCK_EX) + try: + return _update_session_stats_unlocked(session_id, event, tool_name=tool_name, denied=denied) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + except OSError: + # Lock file unavailable — fall back to unlocked update rather than dropping the event + return _update_session_stats_unlocked(session_id, event, tool_name=tool_name, denied=denied) + + def compute_dialogue_flavor(stats): """ Returns a dialogue flavor string based on session rhythm. diff --git a/buddi/Buddi/Services/Shared/UsageService.swift b/buddi/Buddi/Services/Shared/UsageService.swift index 9179639..116143f 100644 --- a/buddi/Buddi/Services/Shared/UsageService.swift +++ b/buddi/Buddi/Services/Shared/UsageService.swift @@ -78,6 +78,9 @@ final class UsageService: ObservableObject { pollTask = Task { guard let token = Self.readOAuthToken() else { isAvailable = false + // Reset interval so a missing token doesn't trap us in the + // 2-second stale-cache fast path forever + currentInterval = baseInterval scheduleNextPoll() return } diff --git a/buddi/components/Notch/NotchHomeView.swift b/buddi/components/Notch/NotchHomeView.swift index 9ca72ce..ff04593 100644 --- a/buddi/components/Notch/NotchHomeView.swift +++ b/buddi/components/Notch/NotchHomeView.swift @@ -542,6 +542,8 @@ struct SectionDropDelegate: DropDelegate { withAnimation(.easeInOut(duration: 0.2)) { order.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to) } + // Persist immediately on every reorder so cancelled drags still keep the new order + onDrop() } func dropExited(info: DropInfo) {