import Foundation // MARK: - ChatGPT (Codex) plan limits // // The ChatGPT/Codex app writes its plan limits into every session log // (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) as // "rate_limits":{"primary":{"used_percent":23.0,"window_minutes":10080,"resets_at":1791634947},"secondary":…} // We read the newest logs once a minute and keep the weekly window (10080 min). // Nothing is sent anywhere; the numbers are only as fresh as the last Codex turn. enum CodexUsage { static let weekMinutes = 10080 /// Weekly window from the most recent `rate_limits` found in the newest session logs. static func readWeekly(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> PlanWindow? { let root = home.appendingPathComponent(".codex/sessions") for file in newestLogs(in: root, limit: 4) { if let w = lastWeekly(in: file) { return w } } return nil } /// Newest *.jsonl files under the date folders, newest first. static func newestLogs(in root: URL, limit: Int) -> [URL] { let fm = FileManager.default guard let e = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) else { return [] } var files: [(URL, Date)] = [] for case let url as URL in e where url.pathExtension == "jsonl" { let d = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast files.append((url, d)) } return files.sorted { $0.1 > $1.1 }.prefix(limit).map(\.0) } /// Reads the tail of a log and returns the weekly window of its last `rate_limits`. static func lastWeekly(in file: URL) -> PlanWindow? { guard let h = try? FileHandle(forReadingFrom: file) else { return nil } defer { try? h.close() } let size = (try? h.seekToEnd()) ?? 0 let chunk: UInt64 = 512 * 1024 try? h.seek(toOffset: size > chunk ? size - chunk : 0) guard let data = try? h.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil } for line in text.split(separator: "\n").reversed() where line.contains("\"rate_limits\"") { if let w = parseLine(String(line)) { return w } } return nil } static func parseLine(_ line: String) -> PlanWindow? { guard let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)), let limits = findRateLimits(obj) else { return nil } let windows = [limits["primary"], limits["secondary"]].compactMap { $0 as? [String: Any] } let weekly = windows.first { ($0["window_minutes"] as? NSNumber)?.intValue == weekMinutes } guard let w = weekly, let pct = (w["used_percent"] as? NSNumber)?.doubleValue, let resets = (w["resets_at"] as? NSNumber)?.doubleValue, resets > 0 else { return nil } return PlanWindow(usedPct: min(100, max(0, pct)), resetsAt: Date(timeIntervalSince1970: resets)) } /// `rate_limits` can sit at any depth of the event (payload.info…), so search for it. private static func findRateLimits(_ obj: Any) -> [String: Any]? { if let d = obj as? [String: Any] { if let r = d["rate_limits"] as? [String: Any] { return r } for v in d.values { if let r = findRateLimits(v) { return r } } } else if let a = obj as? [Any] { for v in a { if let r = findRateLimits(v) { return r } } } return nil } } @MainActor final class CodexUsageMonitor { static let shared = CodexUsageMonitor() private var task: Task? func start() { guard task == nil else { return } task = Task { while !Task.isCancelled { let w = await Task.detached(priority: .utility) { CodexUsage.readWeekly() }.value AppState.shared.codexWeekly = w try? await Task.sleep(for: .seconds(60)) } } } } // MARK: - Reset time wording enum PlanTime { /// "3 ч 20 мин", "2 д 5 ч", "12 мин", "<1 мин". static func untilReset(_ date: Date, now: Date = Date()) -> String { let s = max(0, Int(date.timeIntervalSince(now))) let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60 if d > 0 { return h > 0 ? "\(d) д \(h) ч" : "\(d) д" } if h > 0 { return m > 0 ? "\(h) ч \(m) мин" : "\(h) ч" } return m > 0 ? "\(m) мин" : "<1 мин" } /// "пт 14:00" within a week, "12 окт 14:00" beyond. static func resetMoment(_ date: Date, now: Date = Date()) -> String { let f = DateFormatter() f.locale = Locale(identifier: "ru_RU") f.dateFormat = date.timeIntervalSince(now) < 6 * 86400 ? "EE HH:mm" : "d MMM HH:mm" return f.string(from: date).replacingOccurrences(of: ".", with: "") } }