File size: 10,497 Bytes
b8370f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
import Foundation

enum ChatWorkingClawStance: CaseIterable, Equatable, Sendable {
    case standard
    case southpaw
    case flurry
    case spin
    case shadowbox
    case backflip
    case zen
    case drummer
    case peekaboo
    case nodOff
    case curious
    case omNom
    case fakeOut

    static let weightedStances: [(stance: Self, weight: Double)] = [
        (.standard, 55),
        (.southpaw, 18),
        (.flurry, 5),
        (.spin, 4),
        (.shadowbox, 3),
        (.backflip, 2),
        (.zen, 2),
        (.drummer, 2),
        (.peekaboo, 2),
        (.nodOff, 2),
        (.curious, 2),
        (.omNom, 2),
        (.fakeOut, 1),
    ]

    static func seeded(_ key: String, salt: UInt32) -> Self {
        let totalWeight = self.weightedStances.reduce(0) { $0 + $1.weight }
        var roll = Double((fnv1a(key) ^ salt) % 1000) / 1000 * totalWeight
        for entry in self.weightedStances {
            roll -= entry.weight
            if roll <= 0 {
                return entry.stance
            }
        }
        return .standard
    }
}

enum ChatWorkingPhrase {
    static let showAfterMilliseconds = 30000
    static let rotateEveryMilliseconds = 45000

    /// Keep source phrases localizable; the post-merge native locale refresh
    /// owns the generated catalog entries.
    static let resources: [String] = [
        String(localized: "Shelling"),
        String(localized: "Scuttling"),
        String(localized: "Clawing"),
        String(localized: "Pinching"),
        String(localized: "Molting"),
        String(localized: "Bubbling"),
        String(localized: "Tiding"),
        String(localized: "Reefing"),
        String(localized: "Cracking"),
        String(localized: "Sifting"),
        String(localized: "Brining"),
        String(localized: "Nautiling"),
        String(localized: "Krilling"),
        String(localized: "Barnacling"),
        String(localized: "Lobstering"),
        String(localized: "Tidepooling"),
        String(localized: "Pearling"),
        String(localized: "Snapping"),
        String(localized: "Surfacing"),
    ]

    /// A constant-time stride walk keeps adjacent phrases distinct. The prime
    /// list length makes every non-zero stride visit all phrases before repeating.
    static func index(seed: String, elapsedMilliseconds: Int) -> Int? {
        guard elapsedMilliseconds >= self.showAfterMilliseconds else { return nil }
        let bucket = (elapsedMilliseconds - self.showAfterMilliseconds) / self.rotateEveryMilliseconds
        return self.index(seed: seed, bucket: bucket)
    }

    static func index(seed: String, bucket: Int) -> Int {
        let count = self.resources.count
        let offset = Int(fnv1a("\(seed):offset") % UInt32(count))
        let stride = 1 + Int(fnv1a("\(seed):stride") % UInt32(count - 1))
        return (offset + bucket * stride) % count
    }
}

enum ChatWorkingDurationFormatter {
    private static let compactUnits = [
        (suffix: "d", seconds: 86400),
        (suffix: "h", seconds: 3600),
        (suffix: "m", seconds: 60),
        (suffix: "s", seconds: 1),
    ]

    static func compact(milliseconds: Double) -> String {
        let totalSeconds = self.totalSeconds(milliseconds: milliseconds)
        var remainder = totalSeconds
        var parts: [String] = []
        for unit in self.compactUnits {
            let value = remainder / unit.seconds
            remainder %= unit.seconds
            if value > 0 {
                parts.append("\(value)\(unit.suffix)")
            }
            if parts.count == 2 {
                break
            }
        }
        return parts.joined(separator: " ")
    }

    static func full(milliseconds: Double, locale: Locale = .current) -> String {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.day, .hour, .minute, .second]
        formatter.unitsStyle = .full
        formatter.maximumUnitCount = 2
        formatter.zeroFormattingBehavior = .dropAll
        var calendar = Calendar(identifier: .gregorian)
        calendar.locale = locale
        formatter.calendar = calendar
        return formatter.string(from: TimeInterval(self.totalSeconds(milliseconds: milliseconds))) ??
            String(localized: "1 second", locale: locale)
    }

    private static func totalSeconds(milliseconds: Double) -> Int {
        let finiteMilliseconds = milliseconds.isFinite ? milliseconds : 1000
        let roundedSeconds = (max(1000, finiteMilliseconds) / 1000).rounded()
        return max(1, Int(min(roundedSeconds, Double(Int.max / 2))))
    }
}

enum ChatWorkingIdentity {
    static func resolve(
        sessionKey: String,
        pendingRunIDs: Set<String>,
        localUserMessageIDsByRunID: [String: UUID],
        fallbackGeneration: UInt64) -> String
    {
        if let messageID = pendingRunIDs.compactMap({ localUserMessageIDsByRunID[$0]?.uuidString }).min() {
            return "\(sessionKey):user:\(messageID)"
        }
        if let runID = pendingRunIDs.min() {
            return "\(sessionKey):run:\(runID)"
        }
        return "\(sessionKey):generation:\(fallbackGeneration)"
    }
}

struct ChatTurnRecap: Equatable, Sendable {
    let runtimeMs: Double
    let outputTokens: Int?
}

enum ChatTurnRecapText {
    static func done(runtimeMs: Double, locale: Locale = .current) -> String {
        String(
            format: String(localized: "Done in %@", locale: locale),
            locale: locale,
            ChatWorkingDurationFormatter.full(milliseconds: runtimeMs, locale: locale))
    }

    static func tokens(_ outputTokens: Int?, locale: Locale = .current) -> String? {
        guard let outputTokens else { return nil }
        // Mirrors the web and American-English source contract: exactly one
        // uses the singular key; zero and every other count use the generic key.
        if outputTokens == 1 {
            return String(localized: "1 token", locale: locale)
        }
        return String(
            format: String(localized: "%@ tokens", locale: locale),
            locale: locale,
            outputTokens.formatted(.number.locale(locale)))
    }
}

struct ChatTurnRecapSessionRow: Equatable, Sendable {
    let status: String?
    let endedAt: Double?
    let runtimeMs: Double?
    let outputTokens: Int?

    init(
        status: String? = nil,
        endedAt: Double? = nil,
        runtimeMs: Double? = nil,
        outputTokens: Int? = nil)
    {
        self.status = status
        self.endedAt = endedAt
        self.runtimeMs = runtimeMs
        self.outputTokens = outputTokens
    }

    init(_ entry: OpenClawChatSessionEntry) {
        self.init(
            status: entry.status,
            endedAt: entry.endedAt,
            runtimeMs: entry.runtimeMs,
            outputTokens: entry.outputTokens)
    }
}

/// Watches the turn whose working indicator was visible. Most anomalous
/// interleavings fail quiet; without a row run ID, an unrelated completion
/// inside the settle window remains an accepted cosmetic attribution risk.
struct ChatTurnRecapResolver {
    private struct Watch {
        var watching: Bool
        var baselineKnown: Bool
        var baselineEndedAt: Double?
        var absorbedTerminal: Bool
        var settleStartedAt: Date?
        var settled: ChatTurnRecap?
    }

    private static let settleWindow: TimeInterval = 30
    private var watches: [String: Watch] = [:]

    mutating func resolve(
        sessionKey: String,
        indicatorVisible: Bool,
        row: ChatTurnRecapSessionRow?,
        now: Date = Date()) -> ChatTurnRecap?
    {
        var watch = self.watches[sessionKey]
        let rowEndedAt = row?.endedAt

        if indicatorVisible {
            if watch == nil || watch?.watching == false {
                watch = Watch(
                    watching: true,
                    baselineKnown: row != nil,
                    baselineEndedAt: rowEndedAt,
                    absorbedTerminal: false,
                    settleStartedAt: nil,
                    settled: nil)
            } else if watch?.baselineKnown == false {
                if row != nil {
                    watch?.baselineKnown = true
                    watch?.baselineEndedAt = rowEndedAt
                }
            } else if let rowEndedAt, rowEndedAt != watch?.baselineEndedAt {
                watch?.baselineEndedAt = rowEndedAt
                watch?.absorbedTerminal = true
            }
            self.watches[sessionKey] = watch
            return nil
        }

        guard var watch else { return nil }
        watch.watching = false
        if let settled = watch.settled {
            self.watches[sessionKey] = watch
            return settled
        }
        if watch.absorbedTerminal || !watch.baselineKnown {
            // Without an attributable baseline, later terminal rows could belong
            // to background work, so consume this watch unresolved.
            self.watches[sessionKey] = nil
            return nil
        }
        if let settleStartedAt = watch.settleStartedAt {
            if now.timeIntervalSince(settleStartedAt) > Self.settleWindow {
                self.watches[sessionKey] = nil
                return nil
            }
        } else {
            watch.settleStartedAt = now
        }

        let isStale: Bool = if let rowEndedAt, let baselineEndedAt = watch.baselineEndedAt {
            rowEndedAt <= baselineEndedAt
        } else {
            rowEndedAt == nil
        }
        if isStale {
            // Terminal stamps are monotonic. Equality and regression both mean
            // the watched run's terminal patch has not arrived yet.
            self.watches[sessionKey] = watch
            return nil
        }

        // Any fresh terminal concludes the watch. Only clean done rows with a
        // finite runtime produce a recap; waiting longer could attach another run.
        self.watches[sessionKey] = nil
        guard row?.status == "done", let runtimeMs = row?.runtimeMs, runtimeMs.isFinite else {
            return nil
        }
        let settled = ChatTurnRecap(runtimeMs: runtimeMs, outputTokens: row?.outputTokens)
        watch.settled = settled
        self.watches[sessionKey] = watch
        return settled
    }
}

private func fnv1a(_ key: String) -> UInt32 {
    var hash: UInt32 = 0x811C_9DC5
    for codeUnit in key.utf16 {
        hash ^= UInt32(codeUnit)
        hash = hash &* 0x0100_0193
    }
    return hash
}