diff --git a/buddi/Buddi/Resources/buddi-hook.py b/buddi/Buddi/Resources/buddi-hook.py index a303349..b88394e 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,100 @@ def get_cmux_surface(): return None, None +SESSION_STATS_PATH = os.path.expanduser("~/.buddi-session-stats.json") +SESSION_STATS_LOCK_PATH = SESSION_STATS_PATH + ".lock" + + +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: + 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_unlocked(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 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. + 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 +230,17 @@ def main(): # Map events to status if event == "UserPromptSubmit": - # User just sent a message - Claude is now processing + 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_atomic(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 +258,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 +283,7 @@ def main(): sys.exit(0) elif decision == "deny": + 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 ada2400..116143f 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() } @@ -71,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 } @@ -92,6 +102,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() } @@ -111,11 +123,20 @@ 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, 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 = 2 + } } private func saveCache() { diff --git a/buddi/components/Notch/NotchHomeView.swift b/buddi/components/Notch/NotchHomeView.swift index 1788e76..ff04593 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,31 @@ 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) + } + .onAppear { + // Clear any stale drag state left from a cancelled drag + draggingSection = nil + dragOverSection = nil } if shouldShowCamera { @@ -463,6 +487,125 @@ 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) + } + // Persist immediately on every reorder so cancelled drags still keep the new order + onDrop() + } + + 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 { + 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 {