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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. 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
+ }