mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* fix(chat): render available compaction facts * feat(opencode): unify native compaction summary cards * fix(opencode): report actual compaction application lifecycle * feat(codex): render native compaction checkpoints and metrics * fix(chat): render compaction summaries and token facts in native clients * fix(opencode): preserve transcript priming across read failures * fix(opencode): recover snapshot-only replies after attachment
167 lines
5.8 KiB
Swift
167 lines
5.8 KiB
Swift
import Foundation
|
|
|
|
// Port of web/src/chat/reducerEvents.ts — CLI pipe-format limit parsing,
|
|
// consecutive-event dedupe, api-error folding.
|
|
|
|
private let limitReachedRegex = JSRegex("\\AClaude AI usage limit reached\\|(\\d+)(?:\\|([^|]*))?\\z")
|
|
private let limitWarningRegex = JSRegex("\\AClaude AI usage limit warning\\|(\\d+)\\|(\\d+)\\|([^|]*)\\z")
|
|
|
|
/// Port of `parseClaudeUsageLimit`.
|
|
func parseClaudeUsageLimit(_ text: String) -> AgentEvent? {
|
|
if let match = limitReachedRegex.firstMatch(in: text), let rawTimestamp = match[1] {
|
|
if let timestamp = Double(rawTimestamp), timestamp.isFinite {
|
|
// `reachedMatch[2] || ''` — an absent or empty capture stays ''.
|
|
return .limitReached(endsAt: timestamp, limitType: match.count > 2 ? (match[2] ?? "") : "")
|
|
}
|
|
}
|
|
|
|
if let match = limitWarningRegex.firstMatch(in: text),
|
|
let rawTimestamp = match[1], let rawUtilization = match[2] {
|
|
if let timestamp = Double(rawTimestamp), timestamp.isFinite,
|
|
let utilizationInt = Double(rawUtilization), utilizationInt.isFinite {
|
|
let limitType = match.count > 3 ? (match[3] ?? "") : ""
|
|
return .limitWarning(utilization: utilizationInt / 100, endsAt: timestamp, limitType: limitType)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Port of `parseMessageAsEvent`.
|
|
func parseMessageAsEvent(_ msg: NormalizedMessage) -> AgentEvent? {
|
|
if msg.isSidechain { return nil }
|
|
guard let content = msg.agentContent else { return nil }
|
|
|
|
for item in content {
|
|
if case .text(let text) = item {
|
|
if let limitEvent = parseClaudeUsageLimit(text.text) {
|
|
return limitEvent
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Port of `dedupeAgentEvents`: drops a consecutive repeat of the same
|
|
/// title-changed/message/error event (and, generically, any byte-identical
|
|
/// event), plus a message event echoing the immediately preceding
|
|
/// title-changed title.
|
|
public func dedupeAgentEvents(_ blocks: [ChatBlock]) -> [ChatBlock] {
|
|
var result: [ChatBlock] = []
|
|
var compactions: [String: Int] = [:]
|
|
var prevEventKey: String?
|
|
var prevTitleChangedTo: String?
|
|
|
|
for block in blocks {
|
|
guard case .agentEvent(let eventBlock) = block else {
|
|
result.append(block)
|
|
prevEventKey = nil
|
|
prevTitleChangedTo = nil
|
|
continue
|
|
}
|
|
|
|
let event = eventBlock.event
|
|
if event.type == "compact-summary", let id = event["compactionId"]?.stringValue, !id.isEmpty {
|
|
if let index = compactions[id] {
|
|
if case .agentEvent(var previous) = result[index] {
|
|
var merged = previous.event.object
|
|
if let summary = event["summary"]?.stringValue, !summary.jsTrimmed.isEmpty {
|
|
merged["summary"] = .string(summary)
|
|
}
|
|
for field in ["tokensBefore", "tokensAfter", "estimatedTokensAfter"] {
|
|
if let value = asNumber(event[field]), value.isFinite, value >= 0,
|
|
value <= 9_007_199_254_740_991, value.rounded(.towardZero) == value {
|
|
merged[field] = .number(value)
|
|
}
|
|
}
|
|
previous.event = AgentEvent(object: merged)
|
|
result[index] = .agentEvent(previous)
|
|
}
|
|
continue
|
|
}
|
|
compactions[id] = result.count
|
|
}
|
|
|
|
if event.type == "title-changed", let title = event["title"]?.stringValue {
|
|
let trimmed = title.jsTrimmed
|
|
let key = "title-changed:\(trimmed)"
|
|
if key == prevEventKey {
|
|
continue
|
|
}
|
|
result.append(block)
|
|
prevEventKey = key
|
|
prevTitleChangedTo = trimmed
|
|
continue
|
|
}
|
|
|
|
if event.type == "message", let message = event["message"]?.stringValue {
|
|
let trimmed = message.jsTrimmed
|
|
let key = "message:\(trimmed)"
|
|
if key == prevEventKey {
|
|
continue
|
|
}
|
|
if let prevTitle = prevTitleChangedTo, trimmed == prevTitle {
|
|
continue
|
|
}
|
|
result.append(block)
|
|
prevEventKey = key
|
|
prevTitleChangedTo = nil
|
|
continue
|
|
}
|
|
|
|
if event.type == "error", let message = event["message"]?.stringValue {
|
|
let trimmed = message.jsTrimmed
|
|
let key = "error:\(trimmed)"
|
|
if key == prevEventKey {
|
|
continue
|
|
}
|
|
result.append(block)
|
|
prevEventKey = key
|
|
prevTitleChangedTo = nil
|
|
continue
|
|
}
|
|
|
|
// Generic key: TS uses JSON.stringify(event); the canonical
|
|
// serialization is its deterministic stand-in (fixture inputs are
|
|
// key-sorted, so the reference's insertion order is sorted too).
|
|
let key = "event:\(toCanonicalJSON(event.wireValue))"
|
|
|
|
if key == prevEventKey {
|
|
continue
|
|
}
|
|
|
|
result.append(block)
|
|
prevEventKey = key
|
|
prevTitleChangedTo = nil
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/// Port of `foldApiErrorEvents`: consecutive api-error events keep only the
|
|
/// latest state.
|
|
public func foldApiErrorEvents(_ blocks: [ChatBlock]) -> [ChatBlock] {
|
|
var result: [ChatBlock] = []
|
|
|
|
for block in blocks {
|
|
guard case .agentEvent(let eventBlock) = block else {
|
|
result.append(block)
|
|
continue
|
|
}
|
|
|
|
if eventBlock.event.type != "api-error" {
|
|
result.append(block)
|
|
continue
|
|
}
|
|
|
|
if let last = result.last, case .agentEvent(let prev) = last, prev.event.type == "api-error" {
|
|
result[result.count - 1] = block
|
|
} else {
|
|
result.append(block)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|