codex-flow 2.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public class TelemetryQueryEngine {
|
|
4
|
+
public static let shared = TelemetryQueryEngine()
|
|
5
|
+
|
|
6
|
+
// History/stats refreshes can arrive concurrently from IPC-triggered UI
|
|
7
|
+
// updates. Keep the entire scan under one lock so cachedRuns is never
|
|
8
|
+
// read while another refresh is mutating its Dictionary storage.
|
|
9
|
+
private let cacheLock = NSLock()
|
|
10
|
+
private var cachedRuns: [String: (mtime: Date, run: TaskRun)] = [:]
|
|
11
|
+
private var runsDirURL: URL
|
|
12
|
+
private var lastFileURL: URL
|
|
13
|
+
private var quotaSummaryURL: URL
|
|
14
|
+
|
|
15
|
+
public init(customRoot: URL? = nil) {
|
|
16
|
+
if let root = customRoot {
|
|
17
|
+
self.runsDirURL = root.appendingPathComponent("runs")
|
|
18
|
+
self.lastFileURL = root.appendingPathComponent("last.json")
|
|
19
|
+
self.quotaSummaryURL = root.appendingPathComponent("quota_summary.json")
|
|
20
|
+
} else {
|
|
21
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
22
|
+
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] ?? home.appendingPathComponent(".codex").path
|
|
23
|
+
let telemetryRoot = URL(fileURLWithPath: codexHome)
|
|
24
|
+
.appendingPathComponent("codex-flow")
|
|
25
|
+
.appendingPathComponent("telemetry")
|
|
26
|
+
self.runsDirURL = telemetryRoot.appendingPathComponent("runs")
|
|
27
|
+
self.lastFileURL = telemetryRoot.appendingPathComponent("last.json")
|
|
28
|
+
self.quotaSummaryURL = telemetryRoot.appendingPathComponent("quota_summary.json")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public func loadQuotaSummary() -> QuotaSummary? {
|
|
33
|
+
guard FileManager.default.fileExists(atPath: quotaSummaryURL.path),
|
|
34
|
+
let data = try? Data(contentsOf: quotaSummaryURL) else {
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
return try? JSONDecoder().decode(QuotaSummary.self, from: data)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - Core File Reading & Cache
|
|
41
|
+
|
|
42
|
+
public func loadAllRuns() -> [TaskRun] {
|
|
43
|
+
cacheLock.lock()
|
|
44
|
+
defer { cacheLock.unlock() }
|
|
45
|
+
|
|
46
|
+
guard FileManager.default.fileExists(atPath: runsDirURL.path) else {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
guard let files = try? FileManager.default.contentsOfDirectory(
|
|
51
|
+
at: runsDirURL,
|
|
52
|
+
includingPropertiesForKeys: [.contentModificationDateKey],
|
|
53
|
+
options: [.skipsHiddenFiles]
|
|
54
|
+
) else {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let jsonFiles = files.filter { $0.pathExtension == "json" }
|
|
59
|
+
var result: [TaskRun] = []
|
|
60
|
+
|
|
61
|
+
for fileURL in jsonFiles {
|
|
62
|
+
let stem = fileURL.deletingPathExtension().lastPathComponent
|
|
63
|
+
let mtime = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date.distantPast
|
|
64
|
+
|
|
65
|
+
if let cached = cachedRuns[stem], cached.mtime == mtime {
|
|
66
|
+
if let merged = cached.run.mergedInto, !merged.isEmpty {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
result.append(cached.run)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
guard let data = try? Data(contentsOf: fileURL),
|
|
74
|
+
var run = try? JSONDecoder().decode(TaskRun.self, from: data) else {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
run.fileStem = stem
|
|
79
|
+
cachedRuns[stem] = (mtime, run)
|
|
80
|
+
if let merged = run.mergedInto, !merged.isEmpty {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
result.append(run)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sort descending by finished time or started time
|
|
87
|
+
result.sort { r1, r2 in
|
|
88
|
+
let t1 = r1.finishedAtMs ?? r1.startedAtMs ?? 0
|
|
89
|
+
let t2 = r2.finishedAtMs ?? r2.startedAtMs ?? 0
|
|
90
|
+
return t1 > t2
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public func loadLatestRun() -> TaskRun? {
|
|
97
|
+
if FileManager.default.fileExists(atPath: lastFileURL.path),
|
|
98
|
+
let data = try? Data(contentsOf: lastFileURL),
|
|
99
|
+
let run = try? JSONDecoder().decode(TaskRun.self, from: data) {
|
|
100
|
+
if let merged = run.mergedInto, !merged.isEmpty {
|
|
101
|
+
// Ignore merged run and fall back to loadAllRuns()
|
|
102
|
+
} else {
|
|
103
|
+
return run
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let runs = loadAllRuns()
|
|
107
|
+
return runs.first
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Transcript Insights Extraction
|
|
111
|
+
|
|
112
|
+
public func enrichRunIfNeeded(_ run: inout TaskRun) {
|
|
113
|
+
if (run.trajectory == nil || run.skillsUsed == nil || run.summaryInfo == nil),
|
|
114
|
+
let transcript = run.transcriptPath,
|
|
115
|
+
let insights = parseTranscriptInsights(from: transcript) {
|
|
116
|
+
if run.skillsUsed == nil || run.skillsUsed!.isEmpty {
|
|
117
|
+
run.skillsUsed = insights.skills
|
|
118
|
+
}
|
|
119
|
+
if run.toolsUsed == nil || run.toolsUsed!.isEmpty {
|
|
120
|
+
run.toolsUsed = insights.tools
|
|
121
|
+
}
|
|
122
|
+
if run.trajectory == nil || run.trajectory!.isEmpty {
|
|
123
|
+
run.trajectory = insights.trajectory
|
|
124
|
+
}
|
|
125
|
+
if run.logs == nil || run.logs!.isEmpty {
|
|
126
|
+
run.logs = insights.logs
|
|
127
|
+
}
|
|
128
|
+
if run.summaryInfo == nil {
|
|
129
|
+
run.summaryInfo = insights.summary
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public func parseTranscriptInsights(from pathString: String) -> (skills: [SkillUsage], tools: [ToolCallInfo], trajectory: [TrajectoryStep], logs: [TaskLogEntry], summary: TaskSummaryInfo)? {
|
|
135
|
+
guard FileManager.default.fileExists(atPath: pathString),
|
|
136
|
+
let fileHandle = FileHandle(forReadingAtPath: pathString) else {
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
defer { try? fileHandle.close() }
|
|
140
|
+
|
|
141
|
+
// Read at most last 256KB of transcript to keep memory & parsing instant (< 2ms)
|
|
142
|
+
let fileSize = (try? FileManager.default.attributesOfItem(atPath: pathString)[.size] as? UInt64) ?? 0
|
|
143
|
+
let maxReadBytes: UInt64 = 256 * 1024
|
|
144
|
+
if fileSize > maxReadBytes {
|
|
145
|
+
try? fileHandle.seek(toOffset: fileSize - maxReadBytes)
|
|
146
|
+
}
|
|
147
|
+
let data = fileHandle.readDataToEndOfFile()
|
|
148
|
+
guard let content = String(data: data, encoding: .utf8) else { return nil }
|
|
149
|
+
|
|
150
|
+
var skillsDict: [String: Int] = [:]
|
|
151
|
+
var toolsDict: [String: ToolCallInfo] = [:]
|
|
152
|
+
var trajectory: [TrajectoryStep] = []
|
|
153
|
+
var logs: [TaskLogEntry] = []
|
|
154
|
+
var goal: String? = nil
|
|
155
|
+
var conclusion: String? = nil
|
|
156
|
+
|
|
157
|
+
let lines = content.components(separatedBy: .newlines)
|
|
158
|
+
for line in lines where !line.isEmpty {
|
|
159
|
+
guard let data = line.data(using: .utf8),
|
|
160
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
let ts = obj["timestamp"] as? String
|
|
164
|
+
let recType = obj["type"] as? String
|
|
165
|
+
guard let payload = obj["payload"] as? [String: Any] else { continue }
|
|
166
|
+
|
|
167
|
+
if recType == "response_item" {
|
|
168
|
+
let pType = payload["type"] as? String
|
|
169
|
+
if pType == "custom_tool_call" {
|
|
170
|
+
let toolName = payload["name"] as? String ?? "unknown"
|
|
171
|
+
let rawInput = payload["input"]
|
|
172
|
+
let callId = payload["call_id"] as? String
|
|
173
|
+
let isMcp = toolName.starts(with: "mcp__") || toolName.lowercased().contains("mcp")
|
|
174
|
+
|
|
175
|
+
let inputString: String
|
|
176
|
+
if let s = rawInput as? String {
|
|
177
|
+
inputString = s
|
|
178
|
+
} else if let dict = rawInput as? [String: Any],
|
|
179
|
+
let d = try? JSONSerialization.data(withJSONObject: dict),
|
|
180
|
+
let s = String(data: d, encoding: .utf8) {
|
|
181
|
+
inputString = s
|
|
182
|
+
} else {
|
|
183
|
+
inputString = ""
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if let regex = try? NSRegularExpression(pattern: "skills/([a-zA-Z0-9_\\-]+)") {
|
|
187
|
+
let matches = regex.matches(in: inputString, range: NSRange(inputString.startIndex..., in: inputString))
|
|
188
|
+
for match in matches {
|
|
189
|
+
if let range = Range(match.range(at: 1), in: inputString) {
|
|
190
|
+
let sName = String(inputString[range])
|
|
191
|
+
skillsDict[sName, default: 0] += 1
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if var existing = toolsDict[toolName] {
|
|
197
|
+
existing.count = (existing.count ?? 0) + 1
|
|
198
|
+
toolsDict[toolName] = existing
|
|
199
|
+
} else {
|
|
200
|
+
toolsDict[toolName] = ToolCallInfo(
|
|
201
|
+
name: toolName,
|
|
202
|
+
count: 1,
|
|
203
|
+
isMcp: isMcp,
|
|
204
|
+
category: isMcp ? "mcp" : "system"
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
var inpSummary = ""
|
|
209
|
+
if let cmdRegex = try? NSRegularExpression(pattern: "\"cmd\"\\s*:\\s*\"([^\"]+)\""),
|
|
210
|
+
let match = cmdRegex.firstMatch(in: inputString, range: NSRange(inputString.startIndex..., in: inputString)),
|
|
211
|
+
let range = Range(match.range(at: 1), in: inputString) {
|
|
212
|
+
inpSummary = String(inputString[range]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
213
|
+
} else {
|
|
214
|
+
inpSummary = inputString.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ")
|
|
215
|
+
}
|
|
216
|
+
if inpSummary.count > 160 {
|
|
217
|
+
inpSummary = String(inpSummary.prefix(157)) + "..."
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let cleanTitle = isMcp ? "MCP: \(toolName.replacingOccurrences(of: "mcp__", with: ""))" : "调用 \(toolName)"
|
|
221
|
+
trajectory.append(TrajectoryStep(
|
|
222
|
+
type: "tool_call",
|
|
223
|
+
name: toolName,
|
|
224
|
+
title: cleanTitle,
|
|
225
|
+
detail: inpSummary,
|
|
226
|
+
status: "completed",
|
|
227
|
+
isMcp: isMcp,
|
|
228
|
+
callId: callId,
|
|
229
|
+
timestamp: ts
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
logs.append(TaskLogEntry(
|
|
233
|
+
timestamp: ts,
|
|
234
|
+
level: "info",
|
|
235
|
+
type: "tool_call",
|
|
236
|
+
message: "[\(toolName)] \(inpSummary)"
|
|
237
|
+
))
|
|
238
|
+
} else if pType == "message" {
|
|
239
|
+
let role = payload["role"] as? String
|
|
240
|
+
var msgText = ""
|
|
241
|
+
if let contentList = payload["content"] as? [[String: Any]] {
|
|
242
|
+
for item in contentList {
|
|
243
|
+
if let t = item["text"] as? String {
|
|
244
|
+
msgText += t
|
|
245
|
+
} else if let ot = item["output_text"] as? String {
|
|
246
|
+
msgText += ot
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
msgText = msgText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
251
|
+
if role == "user" && !msgText.isEmpty {
|
|
252
|
+
var cleanPrompt = msgText
|
|
253
|
+
if let r = cleanPrompt.range(of: "<USER_REQUEST>"),
|
|
254
|
+
let endR = cleanPrompt.range(of: "</USER_REQUEST>") {
|
|
255
|
+
cleanPrompt = String(cleanPrompt[r.upperBound..<endR.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
256
|
+
}
|
|
257
|
+
if let reqRange = cleanPrompt.range(of: "## My request:", options: .caseInsensitive) {
|
|
258
|
+
cleanPrompt = String(cleanPrompt[reqRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
259
|
+
}
|
|
260
|
+
if !cleanPrompt.starts(with: "<") || goal == nil {
|
|
261
|
+
goal = cleanPrompt
|
|
262
|
+
}
|
|
263
|
+
} else if role == "assistant" && !msgText.isEmpty {
|
|
264
|
+
conclusion = msgText
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else if recType == "event_msg" && payload["type"] as? String == "item_completed" {
|
|
268
|
+
if let item = payload["item"] as? [String: Any],
|
|
269
|
+
item["type"] as? String == "CommandExecution" {
|
|
270
|
+
let exitCode = item["exit_code"] as? Int ?? 0
|
|
271
|
+
let stdout = (item["stdout"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
272
|
+
if let lastIndex = trajectory.indices.last, trajectory[lastIndex].type == "tool_call" {
|
|
273
|
+
trajectory[lastIndex].status = exitCode == 0 ? "completed" : "error"
|
|
274
|
+
}
|
|
275
|
+
if !stdout.isEmpty {
|
|
276
|
+
logs.append(TaskLogEntry(
|
|
277
|
+
timestamp: ts,
|
|
278
|
+
level: exitCode == 0 ? "info" : "error",
|
|
279
|
+
type: "command_output",
|
|
280
|
+
message: "Exit \(exitCode): \(stdout.prefix(120))"
|
|
281
|
+
))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let skillsList = skillsDict.map { SkillUsage(name: $0.key, count: $0.value) }
|
|
288
|
+
let toolsList = Array(toolsDict.values)
|
|
289
|
+
let summaryInfo = TaskSummaryInfo(
|
|
290
|
+
goal: goal != nil ? String(goal!.prefix(300)) : nil,
|
|
291
|
+
conclusion: conclusion != nil ? String(conclusion!.prefix(500)) : nil
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
skills: skillsList,
|
|
296
|
+
tools: toolsList,
|
|
297
|
+
trajectory: Array(trajectory.suffix(20)),
|
|
298
|
+
logs: Array(logs.suffix(30)),
|
|
299
|
+
summary: summaryInfo
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// MARK: - Query & Filter History (Session / Run Level)
|
|
304
|
+
|
|
305
|
+
public func fetchHistory(
|
|
306
|
+
limit: Int = 100,
|
|
307
|
+
project: String? = nil,
|
|
308
|
+
todayOnly: Bool = false,
|
|
309
|
+
search: String? = nil
|
|
310
|
+
) -> [TaskRun] {
|
|
311
|
+
var runs = loadAllRuns()
|
|
312
|
+
|
|
313
|
+
if todayOnly {
|
|
314
|
+
let calendar = Calendar.current
|
|
315
|
+
let startOfToday = calendar.startOfDay(for: Date()).timeIntervalSince1970 * 1000
|
|
316
|
+
runs = runs.filter { run in
|
|
317
|
+
let ts = run.finishedAtMs ?? run.startedAtMs ?? 0
|
|
318
|
+
return ts >= startOfToday
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if let proj = project, !proj.isEmpty, proj.lowercased() != "all" {
|
|
323
|
+
let pNorm = proj.lowercased()
|
|
324
|
+
runs = runs.filter { run in
|
|
325
|
+
let runProj = run.projectName.lowercased()
|
|
326
|
+
let cwd = (run.cwd ?? "").lowercased()
|
|
327
|
+
return runProj.contains(pNorm) || cwd.contains(pNorm)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if let query = search?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
|
332
|
+
let qNorm = query.lowercased()
|
|
333
|
+
runs = runs.filter { run in
|
|
334
|
+
let title = run.sessionTitle.lowercased()
|
|
335
|
+
let turnText = run.turnPreview.lowercased()
|
|
336
|
+
let proj = run.projectName.lowercased()
|
|
337
|
+
let branch = (run.gitBranch ?? "").lowercased()
|
|
338
|
+
let summary = (run.summary ?? "").lowercased()
|
|
339
|
+
let sess = (run.sessionId ?? "").lowercased()
|
|
340
|
+
let turn = (run.turnId ?? "").lowercased()
|
|
341
|
+
return title.contains(qNorm) || turnText.contains(qNorm) || proj.contains(qNorm) || branch.contains(qNorm) || summary.contains(qNorm) || sess.contains(qNorm) || turn.contains(qNorm)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if limit > 0 && runs.count > limit {
|
|
346
|
+
return Array(runs.prefix(limit))
|
|
347
|
+
}
|
|
348
|
+
return runs
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// MARK: - Query & Filter Chat Groups (Dimension 2: Chat Level)
|
|
352
|
+
|
|
353
|
+
public func fetchChatHistory(
|
|
354
|
+
limit: Int = 50,
|
|
355
|
+
project: String? = nil,
|
|
356
|
+
todayOnly: Bool = false,
|
|
357
|
+
search: String? = nil
|
|
358
|
+
) -> [ChatSession] {
|
|
359
|
+
let allRuns = loadAllRuns()
|
|
360
|
+
if allRuns.isEmpty {
|
|
361
|
+
return []
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 1. Group runs by session_id (or fallback fileStem)
|
|
365
|
+
var groups: [String: [TaskRun]] = [:]
|
|
366
|
+
var groupOrder: [String] = []
|
|
367
|
+
|
|
368
|
+
for run in allRuns {
|
|
369
|
+
let key = run.sessionId ?? run.fileStem ?? UUID().uuidString
|
|
370
|
+
if groups[key] == nil {
|
|
371
|
+
groups[key] = []
|
|
372
|
+
groupOrder.append(key)
|
|
373
|
+
}
|
|
374
|
+
groups[key]!.append(run)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 2. Materialize ChatSession objects
|
|
378
|
+
var chats: [ChatSession] = []
|
|
379
|
+
for key in groupOrder {
|
|
380
|
+
guard let groupRuns = groups[key], !groupRuns.isEmpty else { continue }
|
|
381
|
+
|
|
382
|
+
// Sort runs in this chat descending by timestamp (newest first)
|
|
383
|
+
let sortedRuns = groupRuns.sorted { r1, r2 in
|
|
384
|
+
let t1 = r1.finishedAtMs ?? r1.startedAtMs ?? 0
|
|
385
|
+
let t2 = r2.finishedAtMs ?? r2.startedAtMs ?? 0
|
|
386
|
+
return t1 > t2
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let latestRun = sortedRuns.first!
|
|
390
|
+
let oldestRun = sortedRuns.last!
|
|
391
|
+
|
|
392
|
+
// Derive best project & branch
|
|
393
|
+
let proj = latestRun.projectName
|
|
394
|
+
let branch = sortedRuns.compactMap { $0.gitBranch }.first
|
|
395
|
+
let cwd = latestRun.cwd ?? latestRun.thread?.cwd
|
|
396
|
+
|
|
397
|
+
// Derive best title / prompt from oldest or latest thread preview
|
|
398
|
+
let title = sortedRuns.compactMap { r -> String? in
|
|
399
|
+
if let p = r.thread?.preview, !p.isEmpty { return p }
|
|
400
|
+
if let n = r.thread?.name, !n.isEmpty { return n }
|
|
401
|
+
if let s = r.summary, !s.isEmpty { return s }
|
|
402
|
+
return nil
|
|
403
|
+
}.first ?? latestRun.sessionTitle
|
|
404
|
+
|
|
405
|
+
let summary = sortedRuns.compactMap { $0.summary }.first
|
|
406
|
+
let startedAt = oldestRun.startedAtMs
|
|
407
|
+
let finishedAt = latestRun.finishedAtMs
|
|
408
|
+
let lastActive = sortedRuns.map { $0.finishedAtMs ?? $0.startedAtMs ?? 0 }.max() ?? 0
|
|
409
|
+
|
|
410
|
+
let chat = ChatSession(
|
|
411
|
+
sessionId: key,
|
|
412
|
+
projectName: proj,
|
|
413
|
+
gitBranch: branch,
|
|
414
|
+
cwd: cwd,
|
|
415
|
+
title: title,
|
|
416
|
+
summary: summary,
|
|
417
|
+
startedAtMs: startedAt,
|
|
418
|
+
finishedAtMs: finishedAt,
|
|
419
|
+
lastActiveAtMs: lastActive,
|
|
420
|
+
runs: sortedRuns
|
|
421
|
+
)
|
|
422
|
+
chats.append(chat)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 3. Filter by Today
|
|
426
|
+
if todayOnly {
|
|
427
|
+
let calendar = Calendar.current
|
|
428
|
+
let startOfToday = calendar.startOfDay(for: Date()).timeIntervalSince1970 * 1000
|
|
429
|
+
chats = chats.filter { chat in
|
|
430
|
+
chat.lastActiveAtMs >= startOfToday
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 4. Filter by Project
|
|
435
|
+
if let proj = project, !proj.isEmpty, proj.lowercased() != "all" {
|
|
436
|
+
let pNorm = proj.lowercased()
|
|
437
|
+
chats = chats.filter { chat in
|
|
438
|
+
let cProj = chat.projectName.lowercased()
|
|
439
|
+
let cwd = (chat.cwd ?? "").lowercased()
|
|
440
|
+
return cProj.contains(pNorm) || cwd.contains(pNorm)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 5. Filter by Search Query
|
|
445
|
+
if let query = search?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
|
446
|
+
let qNorm = query.lowercased()
|
|
447
|
+
chats = chats.filter { chat in
|
|
448
|
+
if chat.title.lowercased().contains(qNorm) { return true }
|
|
449
|
+
if chat.projectName.lowercased().contains(qNorm) { return true }
|
|
450
|
+
if (chat.gitBranch ?? "").lowercased().contains(qNorm) { return true }
|
|
451
|
+
if chat.sessionId.lowercased().contains(qNorm) { return true }
|
|
452
|
+
if let sum = chat.summary, sum.lowercased().contains(qNorm) { return true }
|
|
453
|
+
|
|
454
|
+
// Also search across each run within the chat
|
|
455
|
+
for r in chat.runs {
|
|
456
|
+
if r.sessionTitle.lowercased().contains(qNorm) { return true }
|
|
457
|
+
if r.turnPreview.lowercased().contains(qNorm) { return true }
|
|
458
|
+
if (r.summary ?? "").lowercased().contains(qNorm) { return true }
|
|
459
|
+
if (r.turnId ?? "").lowercased().contains(qNorm) { return true }
|
|
460
|
+
if (r.parent?.displayModel ?? "").lowercased().contains(qNorm) { return true }
|
|
461
|
+
for w in r.allWorkers {
|
|
462
|
+
if (w.name ?? "").lowercased().contains(qNorm) { return true }
|
|
463
|
+
if (w.model ?? "").lowercased().contains(qNorm) { return true }
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return false
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 6. Sort descending by last active time
|
|
471
|
+
chats.sort { $0.lastActiveAtMs > $1.lastActiveAtMs }
|
|
472
|
+
|
|
473
|
+
if limit > 0 && chats.count > limit {
|
|
474
|
+
return Array(chats.prefix(limit))
|
|
475
|
+
}
|
|
476
|
+
return chats
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
public func fetchChat(sessionId: String) -> ChatSession? {
|
|
480
|
+
let sid = sessionId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
481
|
+
if sid.isEmpty { return nil }
|
|
482
|
+
let chats = fetchChatHistory(limit: 500)
|
|
483
|
+
return chats.first { $0.sessionId.lowercased() == sid || $0.sessionId.lowercased().contains(sid) }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
public func fetchRun(identifier: String) -> TaskRun? {
|
|
487
|
+
let t = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
488
|
+
if t.isEmpty || t.lowercased() == "last" {
|
|
489
|
+
return loadLatestRun()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let runs = loadAllRuns()
|
|
493
|
+
|
|
494
|
+
// Match #1, #2, etc.
|
|
495
|
+
let numStr = t.hasPrefix("#") ? String(t.dropFirst()) : t
|
|
496
|
+
if let idx = Int(numStr), idx >= 1 && idx <= runs.count {
|
|
497
|
+
return runs[idx - 1]
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Match session ID, turn ID or file stem
|
|
501
|
+
let tLower = t.lowercased()
|
|
502
|
+
for run in runs {
|
|
503
|
+
if let stem = run.fileStem?.lowercased(), stem.contains(tLower) {
|
|
504
|
+
return run
|
|
505
|
+
}
|
|
506
|
+
if let sess = run.sessionId?.lowercased(), sess.contains(tLower) {
|
|
507
|
+
return run
|
|
508
|
+
}
|
|
509
|
+
if let turn = run.turnId?.lowercased(), turn.contains(tLower) {
|
|
510
|
+
return run
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return nil
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// MARK: - Projects List
|
|
518
|
+
|
|
519
|
+
public func allProjects() -> [String] {
|
|
520
|
+
let runs = loadAllRuns()
|
|
521
|
+
var set = Set<String>()
|
|
522
|
+
for r in runs {
|
|
523
|
+
set.insert(r.projectName)
|
|
524
|
+
}
|
|
525
|
+
return Array(set).sorted()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// MARK: - Stats Aggregation
|
|
529
|
+
|
|
530
|
+
public func computeStats(days: Int = 30, project: String? = nil) -> TelemetryStats {
|
|
531
|
+
let runs = loadAllRuns()
|
|
532
|
+
let cutoff = Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970 * 1000
|
|
533
|
+
|
|
534
|
+
var totalRuns = 0
|
|
535
|
+
var delegatedRuns = 0
|
|
536
|
+
var directRuns = 0
|
|
537
|
+
var totalDurationMs: Double = 0
|
|
538
|
+
|
|
539
|
+
var totalTokens = 0
|
|
540
|
+
var parentTokens = 0
|
|
541
|
+
var workerTokens = 0
|
|
542
|
+
|
|
543
|
+
var cachedInputTokens = 0
|
|
544
|
+
var inputTokens = 0
|
|
545
|
+
var outputTokens = 0
|
|
546
|
+
var reasoningOutputTokens = 0
|
|
547
|
+
|
|
548
|
+
var totalCreditsMicros: Double = 0
|
|
549
|
+
var hasCredits = false
|
|
550
|
+
|
|
551
|
+
var modelsDict: [String: (calls: Int, tokens: Int, roles: Set<String>, quotaDelta: Double)] = [:]
|
|
552
|
+
var projectsDict: [String: (runs: Int, delegated: Int, tokens: Int, durationMs: Double, quotaDelta: Double)] = [:]
|
|
553
|
+
|
|
554
|
+
let pFilter = (project != nil && !project!.isEmpty && project!.lowercased() != "all") ? project!.lowercased() : nil
|
|
555
|
+
|
|
556
|
+
for run in runs {
|
|
557
|
+
let ts = run.finishedAtMs ?? run.startedAtMs ?? 0
|
|
558
|
+
if ts < cutoff {
|
|
559
|
+
continue
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let projName = run.projectName
|
|
563
|
+
let cwd = run.cwd ?? ""
|
|
564
|
+
if let pf = pFilter {
|
|
565
|
+
if !projName.lowercased().contains(pf) && !cwd.lowercased().contains(pf) {
|
|
566
|
+
continue
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
totalRuns += 1
|
|
571
|
+
let workers = run.allWorkers
|
|
572
|
+
if !workers.isEmpty {
|
|
573
|
+
delegatedRuns += 1
|
|
574
|
+
} else {
|
|
575
|
+
directRuns += 1
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let dur = run.durationSeconds * 1000.0
|
|
579
|
+
totalDurationMs += dur
|
|
580
|
+
let runDelta = run.primaryQuotaDelta ?? 0
|
|
581
|
+
|
|
582
|
+
// Parent usage
|
|
583
|
+
let parent = run.parent
|
|
584
|
+
let pModel = parent?.displayModel ?? "unknown"
|
|
585
|
+
let pUsage = parent?.effectiveUsage
|
|
586
|
+
|
|
587
|
+
let pTot = pUsage?.totalTokens ?? ((pUsage?.effectivePromptTokens ?? 0) + (pUsage?.effectiveOutputTokens ?? 0))
|
|
588
|
+
let pIn = pUsage?.effectivePromptTokens ?? 0
|
|
589
|
+
let pCin = pUsage?.effectiveCachedTokens ?? 0
|
|
590
|
+
let pOut = pUsage?.effectiveOutputTokens ?? 0
|
|
591
|
+
let pRout = pUsage?.effectiveReasoningTokens ?? 0
|
|
592
|
+
let pCred = pUsage?.estimatedCreditsMicros
|
|
593
|
+
|
|
594
|
+
parentTokens += pTot
|
|
595
|
+
cachedInputTokens += pCin
|
|
596
|
+
inputTokens += pIn
|
|
597
|
+
outputTokens += pOut
|
|
598
|
+
reasoningOutputTokens += pRout
|
|
599
|
+
if let c = pCred, c > 0 {
|
|
600
|
+
totalCreditsMicros += c
|
|
601
|
+
hasCredits = true
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if modelsDict[pModel] == nil {
|
|
605
|
+
modelsDict[pModel] = (0, 0, [], 0)
|
|
606
|
+
}
|
|
607
|
+
modelsDict[pModel]!.calls += 1
|
|
608
|
+
modelsDict[pModel]!.tokens += pTot
|
|
609
|
+
modelsDict[pModel]!.roles.insert("parent")
|
|
610
|
+
|
|
611
|
+
// Worker usage
|
|
612
|
+
var wTotSum = 0
|
|
613
|
+
for w in workers {
|
|
614
|
+
let wModel = w.displayModel
|
|
615
|
+
let wUsage = w.effectiveUsage
|
|
616
|
+
let wTot = wUsage?.totalTokens ?? ((wUsage?.effectivePromptTokens ?? 0) + (wUsage?.effectiveOutputTokens ?? 0))
|
|
617
|
+
let wIn = wUsage?.effectivePromptTokens ?? 0
|
|
618
|
+
let wCin = wUsage?.effectiveCachedTokens ?? 0
|
|
619
|
+
let wOut = wUsage?.effectiveOutputTokens ?? 0
|
|
620
|
+
let wRout = wUsage?.effectiveReasoningTokens ?? 0
|
|
621
|
+
let wCred = wUsage?.estimatedCreditsMicros
|
|
622
|
+
|
|
623
|
+
workerTokens += wTot
|
|
624
|
+
wTotSum += wTot
|
|
625
|
+
cachedInputTokens += wCin
|
|
626
|
+
inputTokens += wIn
|
|
627
|
+
outputTokens += wOut
|
|
628
|
+
reasoningOutputTokens += wRout
|
|
629
|
+
if let c = wCred, c > 0 {
|
|
630
|
+
totalCreditsMicros += c
|
|
631
|
+
hasCredits = true
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if modelsDict[wModel] == nil {
|
|
635
|
+
modelsDict[wModel] = (0, 0, [], 0)
|
|
636
|
+
}
|
|
637
|
+
modelsDict[wModel]!.calls += 1
|
|
638
|
+
modelsDict[wModel]!.tokens += wTot
|
|
639
|
+
modelsDict[wModel]!.roles.insert("worker")
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let runTot = pTot + wTotSum
|
|
643
|
+
totalTokens += runTot
|
|
644
|
+
|
|
645
|
+
if runTot > 0 && runDelta != 0 {
|
|
646
|
+
let pRatio = Double(pTot) / Double(runTot)
|
|
647
|
+
modelsDict[pModel]!.quotaDelta += runDelta * pRatio
|
|
648
|
+
for w in workers {
|
|
649
|
+
let wUsage = w.effectiveUsage
|
|
650
|
+
let wTot = wUsage?.totalTokens ?? ((wUsage?.effectivePromptTokens ?? 0) + (wUsage?.effectiveOutputTokens ?? 0))
|
|
651
|
+
let wRatio = Double(wTot) / Double(runTot)
|
|
652
|
+
modelsDict[w.displayModel]!.quotaDelta += runDelta * wRatio
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if projectsDict[projName] == nil {
|
|
657
|
+
projectsDict[projName] = (0, 0, 0, 0, 0)
|
|
658
|
+
}
|
|
659
|
+
projectsDict[projName]!.runs += 1
|
|
660
|
+
if !workers.isEmpty {
|
|
661
|
+
projectsDict[projName]!.delegated += 1
|
|
662
|
+
}
|
|
663
|
+
projectsDict[projName]!.tokens += runTot
|
|
664
|
+
projectsDict[projName]!.durationMs += dur
|
|
665
|
+
projectsDict[projName]!.quotaDelta += runDelta
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let cacheRatio = inputTokens > 0 ? (Double(cachedInputTokens) / Double(inputTokens) * 100.0) : 0.0
|
|
669
|
+
let workerOffload = totalTokens > 0 ? (Double(workerTokens) / Double(totalTokens) * 100.0) : 0.0
|
|
670
|
+
|
|
671
|
+
let sortedModels = modelsDict.map { k, v in
|
|
672
|
+
ModelStats(
|
|
673
|
+
name: k,
|
|
674
|
+
calls: v.calls,
|
|
675
|
+
tokens: v.tokens,
|
|
676
|
+
roles: Array(v.roles).sorted(),
|
|
677
|
+
quotaDelta: abs(v.quotaDelta) >= 0.01 ? v.quotaDelta : nil
|
|
678
|
+
)
|
|
679
|
+
}.sorted { $0.tokens > $1.tokens }
|
|
680
|
+
|
|
681
|
+
let sortedProjects = projectsDict.map { k, v in
|
|
682
|
+
ProjectStats(
|
|
683
|
+
name: k,
|
|
684
|
+
runs: v.runs,
|
|
685
|
+
delegated: v.delegated,
|
|
686
|
+
tokens: v.tokens,
|
|
687
|
+
durationMs: v.durationMs,
|
|
688
|
+
quotaDelta: abs(v.quotaDelta) >= 0.01 ? v.quotaDelta : nil
|
|
689
|
+
)
|
|
690
|
+
}.sorted { $0.tokens > $1.tokens }
|
|
691
|
+
|
|
692
|
+
return TelemetryStats(
|
|
693
|
+
days: days,
|
|
694
|
+
projectFilter: project,
|
|
695
|
+
totalRuns: totalRuns,
|
|
696
|
+
delegatedRuns: delegatedRuns,
|
|
697
|
+
directRuns: directRuns,
|
|
698
|
+
totalDurationMs: totalDurationMs,
|
|
699
|
+
totalTokens: totalTokens,
|
|
700
|
+
parentTokens: parentTokens,
|
|
701
|
+
workerTokens: workerTokens,
|
|
702
|
+
cachedInputTokens: cachedInputTokens,
|
|
703
|
+
inputTokens: inputTokens,
|
|
704
|
+
outputTokens: outputTokens,
|
|
705
|
+
reasoningOutputTokens: reasoningOutputTokens,
|
|
706
|
+
cacheRatio: cacheRatio,
|
|
707
|
+
workerOffloadRatio: workerOffload,
|
|
708
|
+
estimatedCreditsMicros: hasCredits ? totalCreditsMicros : nil,
|
|
709
|
+
models: sortedModels,
|
|
710
|
+
projects: sortedProjects
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// MARK: - Daily Quota Usage Aggregation
|
|
715
|
+
public func computeDailyQuotaUsage(days: Int = 7, accountId: String? = nil, bucketId: String? = nil) -> [DailyQuotaUsage] {
|
|
716
|
+
let runs = loadAllRuns()
|
|
717
|
+
let summary = loadQuotaSummary()
|
|
718
|
+
|
|
719
|
+
// Daily quota data is only meaningful when the caller supplies both
|
|
720
|
+
// dimensions from the same live account snapshot. A missing dimension
|
|
721
|
+
// must remain unknown; do not select the summary's cached account or a
|
|
722
|
+
// conventional bucket because either can belong to an older context.
|
|
723
|
+
let targetAccountId = accountId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
724
|
+
let targetBucketId = bucketId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
725
|
+
let summaryDailyMap: [String: DailyQuotaItem]
|
|
726
|
+
if let targetAcc = targetAccountId, !targetAcc.isEmpty,
|
|
727
|
+
let targetBucket = targetBucketId, !targetBucket.isEmpty {
|
|
728
|
+
let activeAccountId = summary?.activeAccountId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
729
|
+
if let activeAccountId, !activeAccountId.isEmpty, activeAccountId != targetAcc {
|
|
730
|
+
// Identity inconsistency: summary was exported for a different account.
|
|
731
|
+
summaryDailyMap = [:]
|
|
732
|
+
} else {
|
|
733
|
+
let bucketFiltered = (summary?.dailyUsage ?? []).filter {
|
|
734
|
+
$0.accountId == targetAcc && $0.bucketId == targetBucket
|
|
735
|
+
}
|
|
736
|
+
summaryDailyMap = Dictionary(
|
|
737
|
+
bucketFiltered.map { ($0.date, $0) },
|
|
738
|
+
uniquingKeysWith: { first, _ in first }
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
summaryDailyMap = [:]
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let calendar = Calendar.current
|
|
746
|
+
let today = calendar.startOfDay(for: Date())
|
|
747
|
+
let cutoff = calendar.date(byAdding: .day, value: -(max(1, days) - 1), to: today) ?? today
|
|
748
|
+
|
|
749
|
+
var buckets: [Date: (quotaDelta: Double, tokens: Int, runs: Int)] = [:]
|
|
750
|
+
for run in runs {
|
|
751
|
+
guard let ms = run.finishedAtMs ?? run.startedAtMs else { continue }
|
|
752
|
+
let day = calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000.0))
|
|
753
|
+
guard day >= cutoff && day <= today else { continue }
|
|
754
|
+
let existing = buckets[day] ?? (0.0, 0, 0)
|
|
755
|
+
let delta = run.canonicalQuotaDelta ?? 0.0
|
|
756
|
+
let tokens = run.aggregatedUsage.totalTokens ?? 0
|
|
757
|
+
buckets[day] = (existing.quotaDelta + delta, existing.tokens + tokens, existing.runs + 1)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let dateFormatter = DateFormatter()
|
|
761
|
+
dateFormatter.dateFormat = "yyyy-MM-dd"
|
|
762
|
+
|
|
763
|
+
let shortFormatter = DateFormatter()
|
|
764
|
+
shortFormatter.dateFormat = "MM-dd"
|
|
765
|
+
|
|
766
|
+
return (0..<days).compactMap { offset in
|
|
767
|
+
guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil }
|
|
768
|
+
let dStr = dateFormatter.string(from: day)
|
|
769
|
+
let value = buckets[day] ?? (0.0, 0, 0)
|
|
770
|
+
let item = summaryDailyMap[dStr]
|
|
771
|
+
let rawDelta = item?.observedDeltaPp
|
|
772
|
+
let crossPending = item?.crossMidnightPendingPp
|
|
773
|
+
let coverage = item?.coverageStatus
|
|
774
|
+
let delta: Double?
|
|
775
|
+
if let cov = coverage, cov == "has_cross_midnight_pending", (rawDelta ?? 0.0) == 0.0 {
|
|
776
|
+
delta = nil
|
|
777
|
+
} else {
|
|
778
|
+
delta = rawDelta
|
|
779
|
+
}
|
|
780
|
+
let displayDate: String
|
|
781
|
+
if calendar.isDateInToday(day) {
|
|
782
|
+
displayDate = L("Today", "今天")
|
|
783
|
+
} else if calendar.isDateInYesterday(day) {
|
|
784
|
+
displayDate = L("Yesterday", "昨天")
|
|
785
|
+
} else {
|
|
786
|
+
displayDate = shortFormatter.string(from: day)
|
|
787
|
+
}
|
|
788
|
+
return DailyQuotaUsage(
|
|
789
|
+
date: day,
|
|
790
|
+
dateString: dStr,
|
|
791
|
+
displayDate: displayDate,
|
|
792
|
+
quotaDelta: delta,
|
|
793
|
+
tokens: value.tokens,
|
|
794
|
+
runs: value.runs,
|
|
795
|
+
crossMidnightPendingPp: crossPending,
|
|
796
|
+
coverageStatus: coverage
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|