Files
brov-macbook/NotchBuddy/Sources/App/CodexUsage.swift
T

110 lines
4.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Void, Never>?
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: "").lowercased()
}
}