diff --git a/Sources/App/AppDaemon.swift b/Sources/App/AppDaemon.swift index 708386f..425d370 100644 --- a/Sources/App/AppDaemon.swift +++ b/Sources/App/AppDaemon.swift @@ -41,6 +41,7 @@ final class AppDaemon: ObservableObject { pruneTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in Task { @MainActor [weak self] in self?.prune() } } + ReactiveEngine.shared.start() } /// Clears the tracked sessions (e.g. after disconnecting an integration). diff --git a/Sources/App/BubbleSettings.swift b/Sources/App/BubbleSettings.swift index c403293..cf0994b 100644 --- a/Sources/App/BubbleSettings.swift +++ b/Sources/App/BubbleSettings.swift @@ -319,6 +319,9 @@ final class BubbleSettings: ObservableObject { ActivityFormatter.currentTheme = activityTheme } } + @Published var reactiveBubblesEnabled: Bool { + didSet { ud.set(reactiveBubblesEnabled, forKey: Keys.reactiveBubblesEnabled) } + } // MARK: Computed @@ -357,6 +360,7 @@ final class BubbleSettings: ObservableObject { static let hiddenKinds = "agentpet.bubble.hiddenKinds" static let iconChoices = "agentpet.bubble.iconChoices" static let activityTheme = "agentpet.bubble.activityTheme" + static let reactiveBubblesEnabled = "agentpet.bubble.reactiveBubblesEnabled" } init() { @@ -372,6 +376,7 @@ final class BubbleSettings: ObservableObject { groupByKind = ud.bool(forKey: Keys.groupByKind) displayMode = BubbleDisplayMode(rawValue: ud.string(forKey: Keys.displayMode) ?? "") ?? .carousel multiAgentBubbleEnabled = ud.object(forKey: Keys.multiAgentBubbleEnabled) as? Bool ?? true + reactiveBubblesEnabled = ud.object(forKey: Keys.reactiveBubblesEnabled) as? Bool ?? true hiddenKinds = Set((Self.loadJSON(Keys.hiddenKinds) as [String]? ?? []).compactMap(AgentKind.init(rawValue:))) iconChoices = Self.loadJSON(Keys.iconChoices) ?? [:] activityTheme = ActivityTheme(rawValue: ud.string(forKey: Keys.activityTheme) ?? "") ?? .chef diff --git a/Sources/App/BubbleSettingsView.swift b/Sources/App/BubbleSettingsView.swift index ad807ac..517a815 100644 --- a/Sources/App/BubbleSettingsView.swift +++ b/Sources/App/BubbleSettingsView.swift @@ -47,6 +47,15 @@ struct BubbleSettingsView: View { Spacer() ColorSwitch(isOn: $settings.multiAgentBubbleEnabled) } + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Pet reactive bubbles") + Text("Pet reacts to rate limits, token usage, hunger, and streaks.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + ColorSwitch(isOn: $settings.reactiveBubblesEnabled) + } } header: { Text("Bubble mode") } diff --git a/Sources/App/PetController.swift b/Sources/App/PetController.swift index 4ecd206..62a312d 100644 --- a/Sources/App/PetController.swift +++ b/Sources/App/PetController.swift @@ -170,6 +170,12 @@ final class PetController: ObservableObject { } } + func flashReactiveLine(_ line: String) { + guard showChat else { return } + chatLine = line + StatusBarController.shared.refreshTitle() + } + private func setMood(_ newMood: PetMood) { let changed = newMood != mood mood = newMood diff --git a/Sources/App/ReactiveEngine.swift b/Sources/App/ReactiveEngine.swift new file mode 100644 index 0000000..f4fb451 --- /dev/null +++ b/Sources/App/ReactiveEngine.swift @@ -0,0 +1,267 @@ +import Foundation +import Combine +import AgentPetCore + +// Re-export so test target (import agentpet) can use PetHunger without importing AgentPetCore directly +public typealias PetHunger = AgentPetCore.PetHunger + +// MARK: - ReactiveMetric + +public enum ReactiveMetric: Hashable { + case rateLimit + case dailyTokens + case sessionCount + case hunger + case streak + case dailyMeals +} + +// MARK: - Thresholds + +private enum Thresholds { + enum RateLimit { + static let silent: Double = 0.5 + static let low: Double = 0.15 + static let high: Double = 0.05 + } + enum DailyTokens { + static let silent: Int = 1_000_000 + static let low: Int = 3_000_000 + static let mid: Int = 6_000_000 + } + enum SessionCount { + static let silent: Int = 5 + static let low: Int = 8 + } + enum Streak { + static let silent: Int = 4 + static let low: Int = 7 + static let mid: Int = 14 + } + enum DailyMeals { + static let silent: Int = 20 + static let low: Int = 50 + static let mid: Int = 100 + } + enum Cooldown { + static let sameMetric: TimeInterval = 600 + static let crossMetric: TimeInterval = 30 + } + enum Hunger { + static let dailyLimit: Int = 2 + } +} + +// MARK: - Phrase Pools + +private enum Phrases { + static let rateLimitLow = ["用量開始升喇~", "慢慢嚟,唔急", "留意下 quota"] + static let rateLimitHigh = ["Rate limit 快見底...", "要慳住用喇!", "Quota 唔多喇"] + static let rateLimitCritical = ["差唔多冇 quota 喇 😰", "停一停吖...", "Quota 幾乎用完"] + + static let dailyTokensLow = ["今日燒咗唔少 token~", "食咗好多 token 喇", "Token 消耗上升中"] + static let dailyTokensMid = ["大胃王模式!", "今日胃口好好~", "Token 食得好快"] + static let dailyTokensHigh = ["今日 token 用量爆炸 🔥", "Token 消耗超高!", "今日 burn 好勁"] + + static let sessionCountLow = ["5 個 agent 齊開~", "好多 agent 喺度做嘢", "並行度唔低"] + static let sessionCountHigh = ["指揮中心模式 😳", "咁多 session!", "全力運作中"] + + static let hungerLow = ["有少少肚餓...", "嗯...想食嘢", "肚子打鼓"] + static let hungerMid = ["好耐冇餵我喇 😢", "肚餓...", "想食嘢..."] + static let hungerHigh = ["你去咗邊... 😭", "快要餓暈喇", "好肚餓啊"] + + static let streakLow = ["連續幾日!繼續加油", "連續好幾日~", "堅持緊"] + static let streakMid = ["成個禮拜冇斷過!", "好有毅力~", "太穩定喇"] + static let streakHigh = ["傳說級連續!", "太強喇!", "無人能擋"] + + static let dailyMealsLow = ["今日完成好多 session~", "效率唔錯", "做咗唔少嘢"] + static let dailyMealsMid = ["半百 session!效率怪物", "50+!", "超高產"] + static let dailyMealsHigh = ["破百!你今日唔駛瞓?", "100+ session!", "超人嚟嘅"] +} + +// MARK: - CooldownTracker + +private struct CooldownTracker { + private var lastFiredAt: [ReactiveMetric: Date] = [:] + private var lastAnyFiredAt: Date? = nil + private var hungerDayKey: String = "" + private var hungerDayCount: Int = 0 + + private static let utcCalendar: Calendar = { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal + }() + + private func dayKey(for date: Date) -> String { + let c = Self.utcCalendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) + } + + /// Returns true if allowed to fire. Mutates state if allowed. + mutating func check(metric: ReactiveMetric, now: Date) -> Bool { + // Same-metric gate takes priority + if let last = lastFiredAt[metric] { + if now.timeIntervalSince(last) < Thresholds.Cooldown.sameMetric { + return false + } + } + + // Cross-metric gate: only applies when a *different* metric fired most recently + let lastAnyIsOtherMetric = lastAnyFiredAt != nil && lastAnyFiredAt != lastFiredAt[metric] + if lastAnyIsOtherMetric, + let lastAny = lastAnyFiredAt, + now.timeIntervalSince(lastAny) < Thresholds.Cooldown.crossMetric { + return false + } + + // Hunger daily limit + if metric == .hunger { + let today = dayKey(for: now) + if hungerDayKey != today { + hungerDayKey = today + hungerDayCount = 0 + } + if hungerDayCount >= Thresholds.Hunger.dailyLimit { return false } + hungerDayCount += 1 + } + + lastFiredAt[metric] = now + lastAnyFiredAt = now + return true + } +} + +// MARK: - ReactiveEngine + +@MainActor +public final class ReactiveEngine { + + public static let shared = ReactiveEngine() + + private var cooldown = CooldownTracker() + private var cancellables = Set() + + public init() {} + + @discardableResult + public func evaluate(metric: ReactiveMetric, value: AnyHashable?, now: Date = .now) -> String? { + guard let phrases = phrasePool(metric: metric, value: value) else { return nil } + guard BubbleSettings.shared.reactiveBubblesEnabled else { return nil } + guard cooldown.check(metric: metric, now: now) else { return nil } + return phrases.randomElement() + } + + // MARK: - Subscriptions + + public func start() { + // Sink 1: OpenUsageClient + OpenUsageClient.shared.$providers + .receive(on: RunLoop.main) + .sink { [weak self] _ in + let value = OpenUsageClient.shared.lowestFractionLeft + if let line = self?.evaluate(metric: .rateLimit, value: value.map { AnyHashable($0) }) { + PetController.shared.flashReactiveLine(line) + } + } + .store(in: &cancellables) + + // Sink 2: PetCareController (evaluates 4 metrics) + PetCareController.shared.$states + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + let care = PetCareController.shared + let current = care.current + + // dailyTokens — use days[todayKey] for real value + let todayKey = Self.todayKey() + let realTokens = current.days?[todayKey] ?? 0 + if let line = self.evaluate(metric: .dailyTokens, value: AnyHashable(realTokens)) { + PetController.shared.flashReactiveLine(line) + } + + // streak + if let line = self.evaluate(metric: .streak, value: AnyHashable(current.streakDays)) { + PetController.shared.flashReactiveLine(line) + } + + // dailyMeals + if let line = self.evaluate(metric: .dailyMeals, value: AnyHashable(current.mealsToday)) { + PetController.shared.flashReactiveLine(line) + } + + // hunger + let hunger = care.hunger + if let line = self.evaluate(metric: .hunger, value: AnyHashable(hunger)) { + PetController.shared.flashReactiveLine(line) + } + } + .store(in: &cancellables) + + // Sink 3: AppDaemon sessions + AppDaemon.shared.$sessions + .receive(on: RunLoop.main) + .sink { [weak self] sessions in + if let line = self?.evaluate(metric: .sessionCount, value: AnyHashable(sessions.count)) { + PetController.shared.flashReactiveLine(line) + } + } + .store(in: &cancellables) + } + + private static func todayKey() -> String { + let c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) + } + + // MARK: - Tier resolution + + private func phrasePool(metric: ReactiveMetric, value: AnyHashable?) -> [String]? { + switch metric { + case .rateLimit: + guard let v = value.flatMap({ $0.base as? Double }) else { return nil } + if v > Thresholds.RateLimit.silent { return nil } + if v > Thresholds.RateLimit.low { return Phrases.rateLimitLow } + if v > Thresholds.RateLimit.high { return Phrases.rateLimitHigh } + return Phrases.rateLimitCritical + + case .dailyTokens: + guard let v = value.flatMap({ $0.base as? Int }) else { return nil } + if v < Thresholds.DailyTokens.silent { return nil } + if v < Thresholds.DailyTokens.low { return Phrases.dailyTokensLow } + if v < Thresholds.DailyTokens.mid { return Phrases.dailyTokensMid } + return Phrases.dailyTokensHigh + + case .sessionCount: + guard let v = value.flatMap({ $0.base as? Int }) else { return nil } + if v < Thresholds.SessionCount.silent { return nil } + if v < Thresholds.SessionCount.low { return Phrases.sessionCountLow } + return Phrases.sessionCountHigh + + case .hunger: + guard let h = value.flatMap({ $0.base as? PetHunger }) else { return nil } + switch h { + case .full, .satisfied: return nil + case .peckish: return Phrases.hungerLow + case .hungry: return Phrases.hungerMid + case .starving: return Phrases.hungerHigh + } + + case .streak: + guard let v = value.flatMap({ $0.base as? Int }) else { return nil } + if v < Thresholds.Streak.silent { return nil } + if v < Thresholds.Streak.low { return Phrases.streakLow } + if v < Thresholds.Streak.mid { return Phrases.streakMid } + return Phrases.streakHigh + + case .dailyMeals: + guard let v = value.flatMap({ $0.base as? Int }) else { return nil } + if v < Thresholds.DailyMeals.silent { return nil } + if v < Thresholds.DailyMeals.low { return Phrases.dailyMealsLow } + if v < Thresholds.DailyMeals.mid { return Phrases.dailyMealsMid } + return Phrases.dailyMealsHigh + } + } +} + diff --git a/Tests/AgentPetAppTests/ReactiveEngineTests.swift b/Tests/AgentPetAppTests/ReactiveEngineTests.swift new file mode 100644 index 0000000..acd15c5 --- /dev/null +++ b/Tests/AgentPetAppTests/ReactiveEngineTests.swift @@ -0,0 +1,282 @@ +import XCTest +@testable import agentpet + +@MainActor +final class ReactiveEngineTests: XCTestCase { + + private func makeEngine() -> ReactiveEngine { + ReactiveEngine() + } + + func testRateLimitAbove50PercentIsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: 0.6) + XCTAssertNil(result) + } + + func testRateLimitBetween15And50PercentIsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: 0.30) + XCTAssertNotNil(result) + } + + func testRateLimitBetween5And15PercentIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: 0.10) + XCTAssertNotNil(result) + } + + func testRateLimitBelow5PercentIsCritical() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: 0.03) + XCTAssertNotNil(result) + } + + func testRateLimitNilValueReturnsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: nil as Double?) + XCTAssertNil(result) + } + + func testDailyTokensBelow1MIsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyTokens, value: 800_000) + XCTAssertNil(result) + } + + func testDailyTokensBetween1MAnd3MIsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyTokens, value: 2_000_000) + XCTAssertNotNil(result) + } + + func testDailyTokensBetween3MAnd6MIsMid() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyTokens, value: 4_500_000) + XCTAssertNotNil(result) + } + + func testDailyTokensAbove6MIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyTokens, value: 7_000_000) + XCTAssertNotNil(result) + } + + func testSessionCount1To4IsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .sessionCount, value: 3) + XCTAssertNil(result) + } + + func testSessionCount5To7IsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .sessionCount, value: 6) + XCTAssertNotNil(result) + } + + func testSessionCount8OrMoreIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .sessionCount, value: 8) + XCTAssertNotNil(result) + } + + func testHungerFullIsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .hunger, value: PetHunger.full) + XCTAssertNil(result) + } + + func testHungerSatisfiedIsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .hunger, value: PetHunger.satisfied) + XCTAssertNil(result) + } + + func testHungerPeckishIsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .hunger, value: PetHunger.peckish) + XCTAssertNotNil(result) + } + + func testHungerHungryIsMid() { + let engine = makeEngine() + let result = engine.evaluate(metric: .hunger, value: PetHunger.hungry) + XCTAssertNotNil(result) + } + + func testHungerStarvingIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .hunger, value: PetHunger.starving) + XCTAssertNotNil(result) + } + + func testStreak1To3IsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .streak, value: 2) + XCTAssertNil(result) + } + + func testStreak4To6IsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .streak, value: 5) + XCTAssertNotNil(result) + } + + func testStreak7To13IsMid() { + let engine = makeEngine() + let result = engine.evaluate(metric: .streak, value: 10) + XCTAssertNotNil(result) + } + + func testStreak14OrMoreIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .streak, value: 14) + XCTAssertNotNil(result) + } + + func testDailyMealsBelow20IsSilent() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyMeals, value: 10) + XCTAssertNil(result) + } + + func testDailyMeals20To50IsLow() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyMeals, value: 35) + XCTAssertNotNil(result) + } + + func testDailyMeals50To100IsMid() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyMeals, value: 75) + XCTAssertNotNil(result) + } + + func testDailyMeals100OrMoreIsHigh() { + let engine = makeEngine() + let result = engine.evaluate(metric: .dailyMeals, value: 100) + XCTAssertNotNil(result) + } + + func testSameMetricBlockedWithin600Seconds() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + let first = engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: now) + XCTAssertNotNil(first) + let secondNow = now.addingTimeInterval(599) + let second = engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: secondNow) + XCTAssertNil(second) + } + + func testSameMetricAllowedAfter600Seconds() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: now) + let laterNow = now.addingTimeInterval(600) + let result = engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: laterNow) + XCTAssertNotNil(result) + } + + func testCrossMetricBlockedWithin30Seconds() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: now) + let laterNow = now.addingTimeInterval(29) + let result = engine.evaluate(metric: .sessionCount, value: 6, now: laterNow) + XCTAssertNil(result) + } + + func testCrossMetricAllowedAfter30Seconds() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: now) + let laterNow = now.addingTimeInterval(30) + let result = engine.evaluate(metric: .sessionCount, value: 6, now: laterNow) + XCTAssertNotNil(result) + } + + func testSameMetricGateTakesPriorityOverCrossMetricGate() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: now) + let laterNow = now.addingTimeInterval(35) + let result = engine.evaluate(metric: .dailyTokens, value: 2_000_000, now: laterNow) + XCTAssertNil(result) + } + + func testHungerDailyLimitBlocksThirdTriggerSameDay() { + let engine = makeEngine() + let base = Date(timeIntervalSince1970: 1_750_000_000) + let t1 = engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: base) + XCTAssertNotNil(t1) + let t2 = engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: base.addingTimeInterval(600)) + XCTAssertNotNil(t2) + let t3 = engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: base.addingTimeInterval(1200)) + XCTAssertNil(t3) + } + + func testHungerDailyLimitResetsNextDay() { + let engine = makeEngine() + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.timeZone = TimeZone(identifier: "UTC") + let day1 = formatter.date(from: "2026-06-17 10:00")! + let day2 = formatter.date(from: "2026-06-18 10:00")! + engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: day1) + engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: day1.addingTimeInterval(600)) + let blocked = engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: day1.addingTimeInterval(1200)) + XCTAssertNil(blocked) + let tomorrow = engine.evaluate(metric: .hunger, value: PetHunger.hungry, now: day2) + XCTAssertNotNil(tomorrow) + } + + func testSilentTierReturnsNil() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: 0.9) + XCTAssertNil(result) + } + + func testNonSilentTierReturnsNonNilString() { + let engine = makeEngine() + let result = engine.evaluate(metric: .streak, value: 14) + XCTAssertNotNil(result) + XCTAssertFalse(result?.isEmpty ?? true) + } + + func testNilValueReturnsNil() { + let engine = makeEngine() + let result = engine.evaluate(metric: .rateLimit, value: nil as Double?) + XCTAssertNil(result) + } + + func testCooldownBlockReturnsNil() { + let engine = makeEngine() + let now = Date(timeIntervalSince1970: 1_000_000) + let first = engine.evaluate(metric: .streak, value: 14, now: now) + XCTAssertNotNil(first) + let second = engine.evaluate(metric: .streak, value: 14, now: now.addingTimeInterval(1)) + XCTAssertNil(second) + } +} + +private extension ReactiveEngine { + @discardableResult + func evaluate(metric: ReactiveMetric, value: Double?, now: Date = .now) -> String? { + evaluate(metric: metric, value: value.map { AnyHashable($0) }, now: now) + } + + @discardableResult + func evaluate(metric: ReactiveMetric, value: Double, now: Date = .now) -> String? { + evaluate(metric: metric, value: AnyHashable(value), now: now) + } + + @discardableResult + func evaluate(metric: ReactiveMetric, value: Int, now: Date = .now) -> String? { + evaluate(metric: metric, value: AnyHashable(value), now: now) + } + + @discardableResult + func evaluate(metric: ReactiveMetric, value: PetHunger, now: Date = .now) -> String? { + evaluate(metric: metric, value: AnyHashable(value), now: now) + } +}