diff --git a/Sources/AgentDockCore/BoundedFileReader.swift b/Sources/AgentDockCore/BoundedFileReader.swift index 2bafcfd..c8979c5 100644 --- a/Sources/AgentDockCore/BoundedFileReader.swift +++ b/Sources/AgentDockCore/BoundedFileReader.swift @@ -90,5 +90,4 @@ enum LocalControlFileLimit { static let providerCredentialState = 4 * 1_024 * 1_024 static let propertyList = 1 * 1_024 * 1_024 static let customIcon = 10 * 1_024 * 1_024 - static let chatIndex = 8 * 1_024 * 1_024 } diff --git a/Sources/AgentDockCore/LocalChatSession.swift b/Sources/AgentDockCore/LocalChatSession.swift index f33a815..7c895fd 100644 --- a/Sources/AgentDockCore/LocalChatSession.swift +++ b/Sources/AgentDockCore/LocalChatSession.swift @@ -1,11 +1,6 @@ import Foundation import Darwin -public enum LocalChatAvailability: Equatable, Sendable { - case available - case unavailable(String) -} - private struct ClaudeUsageSummary: Sendable { let totalTokens: Int let latestUsageLimit: UsageLimitSignal? @@ -82,158 +77,17 @@ private final class ClaudeUsageCache: @unchecked Sendable { } } -public enum LocalChatRole: String, Codable, Sendable { - case user - case assistant -} - -public struct LocalChatMessage: Identifiable, Hashable, Sendable { - public let id: String - public let role: LocalChatRole - public let text: String - public let timestamp: Date? - - public init( - id: String = UUID().uuidString, - role: LocalChatRole, - text: String, - timestamp: Date? - ) { - self.id = id - self.role = role - self.text = text - self.timestamp = timestamp - } -} - -public enum LocalChatTranscriptEntryKind: String, Codable, Sendable { - case message - case reasoning - case activity - case command - case file - case error - case status - case malformed - case oversized - case unsupported -} - -/// Renderer-facing, source-backed transcript data. The UI decides how compact -/// or collapsible non-message entries should be. -public struct LocalChatTranscriptEntry: Identifiable, Hashable, Sendable { - public let id: String - public let kind: LocalChatTranscriptEntryKind - public let role: LocalChatRole? - public let title: String? - public let text: String - public let timestamp: Date? - public let isCollapsible: Bool - public let sourceOrdinal: Int? - - public init( - id: String, - kind: LocalChatTranscriptEntryKind, - role: LocalChatRole? = nil, - title: String? = nil, - text: String, - timestamp: Date? = nil, - isCollapsible: Bool = false, - sourceOrdinal: Int? = nil - ) { - self.id = id - self.kind = kind - self.role = role - self.title = title - self.text = text - self.timestamp = timestamp - self.isCollapsible = isCollapsible - self.sourceOrdinal = sourceOrdinal - } - - public var message: LocalChatMessage? { - guard kind == .message, let role else { return nil } - return LocalChatMessage(id: id, role: role, text: text, timestamp: timestamp) - } - - func withSourceOrdinal(_ value: Int) -> Self { - .init( - id: id, - kind: kind, - role: role, - title: title, - text: text, - timestamp: timestamp, - isCollapsible: isCollapsible, - sourceOrdinal: value - ) - } -} - -public struct LocalChatTranscriptCursor: Hashable, Sendable { - public let byteOffset: UInt64 - public let sourceSize: UInt64 - public let sourceModifiedAt: Date - public let sourceIdentifier: String? - public let skippingOversizedLine: Bool - public let nextSequence: Int - - public init( - byteOffset: UInt64, - sourceSize: UInt64, - sourceModifiedAt: Date, - sourceIdentifier: String? = nil, - skippingOversizedLine: Bool = false, - nextSequence: Int = 0 - ) { - self.byteOffset = byteOffset - self.sourceSize = sourceSize - self.sourceModifiedAt = sourceModifiedAt - self.sourceIdentifier = sourceIdentifier - self.skippingOversizedLine = skippingOversizedLine - self.nextSequence = nextSequence - } -} - -public struct LocalChatTranscriptPage: Sendable { - public let entries: [LocalChatTranscriptEntry] - public let olderCursor: LocalChatTranscriptCursor? - public let sourceChanged: Bool - public let malformedEventCount: Int - - public init( - entries: [LocalChatTranscriptEntry], - olderCursor: LocalChatTranscriptCursor?, - sourceChanged: Bool, - malformedEventCount: Int - ) { - self.entries = entries - self.olderCursor = olderCursor - self.sourceChanged = sourceChanged - self.malformedEventCount = malformedEventCount - } -} - public struct LocalChatSession: Identifiable, Hashable, Sendable { public let id: String public let provider: DesktopProduct public let profileID: CodexProfile.ID? public let profileName: String - public let title: String - public let preview: String? public let model: String? - public let repository: String? - public let branch: String? public let startedAt: Date public let updatedAt: Date public let tokenCount: Int? public let latestUsageLimit: UsageLimitSignal? public let status: String - public let sourceURL: URL - let sourceRootURL: URL - public let sourceSize: UInt64 - public let sourceModifiedAt: Date - public let messages: [LocalChatMessage] public var duration: TimeInterval { max(0, updatedAt.timeIntervalSince(startedAt)) @@ -244,64 +98,23 @@ public struct LocalChatSession: Identifiable, Hashable, Sendable { provider: DesktopProduct, profileID: CodexProfile.ID?, profileName: String, - title: String, - preview: String?, model: String?, - repository: String?, - branch: String?, startedAt: Date, updatedAt: Date, tokenCount: Int?, latestUsageLimit: UsageLimitSignal? = nil, - status: String, - sourceURL: URL, - sourceRootURL: URL, - sourceSize: UInt64, - sourceModifiedAt: Date, - messages: [LocalChatMessage] = [] + status: String ) { self.id = id self.provider = provider self.profileID = profileID self.profileName = profileName - self.title = title - self.preview = preview self.model = model - self.repository = repository - self.branch = branch self.startedAt = startedAt self.updatedAt = updatedAt self.tokenCount = tokenCount self.latestUsageLimit = latestUsageLimit self.status = status - self.sourceURL = sourceURL - self.sourceRootURL = sourceRootURL - self.sourceSize = sourceSize - self.sourceModifiedAt = sourceModifiedAt - self.messages = messages - } - - public func replacingMessages(_ messages: [LocalChatMessage]) -> Self { - Self( - id: id, - provider: provider, - profileID: profileID, - profileName: profileName, - title: title, - preview: preview, - model: model, - repository: repository, - branch: branch, - startedAt: startedAt, - updatedAt: updatedAt, - tokenCount: tokenCount, - status: status, - sourceURL: sourceURL, - sourceRootURL: sourceRootURL, - sourceSize: sourceSize, - sourceModifiedAt: sourceModifiedAt, - messages: messages - ) } } @@ -309,7 +122,6 @@ public struct LocalChatScanDiagnostics: Equatable, Sendable { public let cacheHitCount: Int public let parsedFileCount: Int public let sourceFileCount: Int - public let usedDatabase: Bool public let inventoryTruncated: Bool public let inspectedHistoryBytes: Int @@ -317,49 +129,36 @@ public struct LocalChatScanDiagnostics: Equatable, Sendable { cacheHitCount: Int = 0, parsedFileCount: Int = 0, sourceFileCount: Int = 0, - usedDatabase: Bool = false, inventoryTruncated: Bool = false, inspectedHistoryBytes: Int = 0 ) { self.cacheHitCount = cacheHitCount self.parsedFileCount = parsedFileCount self.sourceFileCount = sourceFileCount - self.usedDatabase = usedDatabase self.inventoryTruncated = inventoryTruncated self.inspectedHistoryBytes = inspectedHistoryBytes } } public struct LocalChatScanResult: Sendable { - public let availability: LocalChatAvailability public let sessions: [LocalChatSession] - public let changeToken: String public let diagnostics: LocalChatScanDiagnostics public init( - availability: LocalChatAvailability, sessions: [LocalChatSession], - changeToken: String = "", diagnostics: LocalChatScanDiagnostics = .init() ) { - self.availability = availability self.sessions = sessions - self.changeToken = changeToken self.diagnostics = diagnostics } } public struct LocalChatScanner: @unchecked Sendable { - private static let sqliteURL = URL(fileURLWithPath: "/usr/bin/sqlite3") - private static let indexVersion = 2 private static let indexMutationLock = NSLock() private let fileManager: FileManager private let maximumSessions: Int private let maximumSummaryLineBytes: Int - private let transcriptPageBytes: Int - private let transcriptPageEntries: Int - private let maximumRenderableEntryBytes: Int private let maximumInventoryFiles: Int private let maximumMetadataBytes: Int private let maximumClaudeUsageBytes: Int @@ -371,9 +170,6 @@ public struct LocalChatScanner: @unchecked Sendable { fileManager: FileManager = .default, maximumSessions: Int = 1_000, maximumSummaryLineBytes: Int = 2 * 1_024 * 1_024, - transcriptPageBytes: Int = 1 * 1_024 * 1_024, - transcriptPageEntries: Int = 60, - maximumRenderableEntryBytes: Int = 2 * 1_024 * 1_024, maximumInventoryFiles: Int? = nil, maximumMetadataBytes: Int = 16 * 1_024 * 1_024, maximumClaudeUsageBytes: Int = 512 * 1_024 * 1_024, @@ -383,9 +179,6 @@ public struct LocalChatScanner: @unchecked Sendable { self.fileManager = fileManager self.maximumSessions = maximumSessions self.maximumSummaryLineBytes = maximumSummaryLineBytes - self.transcriptPageBytes = transcriptPageBytes - self.transcriptPageEntries = transcriptPageEntries - self.maximumRenderableEntryBytes = maximumRenderableEntryBytes self.maximumInventoryFiles = max( 1, maximumInventoryFiles @@ -400,64 +193,21 @@ public struct LocalChatScanner: @unchecked Sendable { } func scanForStats(profile: CodexProfile) -> LocalChatScanResult { - guard profile.product == .claude else { return scan(profile: profile) } + guard profile.product == .claude else { return .init(sessions: []) } return scanClaudeUserData( userDataURL: profile.claudeUserDataPath, - profileID: profile.id, profileName: profile.name, forStats: true - ) - } - - public func scan(profile: CodexProfile) -> LocalChatScanResult { - switch profile.product { - case .codex: - return scanCodex( - codexHomeURL: profile.codexHomePath, - profileID: profile.id, - profileName: profile.name - ) - case .claude: - return scanClaudeUserData( - userDataURL: profile.claudeUserDataPath, - profileID: profile.id, - profileName: profile.name - ) - } - } - - public func scanOfficialCodex(codexHomeURL: URL) -> LocalChatScanResult { - scanCodex( - codexHomeURL: codexHomeURL, - profileID: nil, - profileName: "Official Codex" + profileID: profile.id, profileName: profile.name ) } - public func scanOfficialClaude( - claudeHomeURL: URL, - claudeCodeHomeURL: URL? = nil - ) -> LocalChatScanResult { - scanOfficialClaude(claudeHomeURL: claudeHomeURL, claudeCodeHomeURL: claudeCodeHomeURL, forStats: false) - } - func scanOfficialClaudeForStats(claudeHomeURL: URL, claudeCodeHomeURL: URL) -> LocalChatScanResult { - scanOfficialClaude(claudeHomeURL: claudeHomeURL, claudeCodeHomeURL: claudeCodeHomeURL, forStats: true) - } - - private func scanOfficialClaude( - claudeHomeURL: URL, claudeCodeHomeURL: URL?, forStats: Bool - ) -> LocalChatScanResult { let primary = scanClaudeUserData( userDataURL: claudeHomeURL, profileID: nil, - profileName: "Official Claude", - forStats: forStats + profileName: "Official Claude" ) - let fallbackRoot = claudeCodeHomeURL - ?? (fileManager.fileExists( - atPath: claudeHomeURL.appendingPathComponent("history.jsonl").path - ) ? claudeHomeURL : nil) - guard let fallbackRoot, !Task.isCancelled else { return primary } - let fallback = scanClaudeCode(claudeHomeURL: fallbackRoot, forStats: forStats) + guard !Task.isCancelled else { return primary } + let fallback = scanClaudeCode(claudeHomeURL: claudeCodeHomeURL) let sorted = (primary.sessions + fallback.sessions).sorted { if $0.updatedAt == $1.updatedAt { return $0.id > $1.id } return $0.updatedAt > $1.updatedAt @@ -465,9 +215,7 @@ public struct LocalChatScanner: @unchecked Sendable { var seenIDs: Set = [] let sessions = sorted.filter { seenIDs.insert($0.id).inserted } return .init( - availability: .available, sessions: Array(sessions.prefix(maximumSessions)), - changeToken: forStats ? "" : "\(primary.changeToken):\(fallback.changeToken)", diagnostics: .init( cacheHitCount: primary.diagnostics.cacheHitCount + fallback.diagnostics.cacheHitCount, parsedFileCount: primary.diagnostics.parsedFileCount @@ -481,32 +229,6 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - public func changeToken(profile: CodexProfile) -> String { - switch profile.product { - case .codex: - return sourceChangeToken(codexHomeURL: profile.codexHomePath) - case .claude: - return claudeUserDataChangeToken(userDataURL: profile.claudeUserDataPath) - } - } - - public func officialCodexChangeToken(codexHomeURL: URL) -> String { - sourceChangeToken(codexHomeURL: codexHomeURL) - } - - public func officialClaudeChangeToken( - claudeHomeURL: URL, - claudeCodeHomeURL: URL? = nil - ) -> String { - let primary = claudeUserDataChangeToken(userDataURL: claudeHomeURL) - let fallbackRoot = claudeCodeHomeURL - ?? (fileManager.fileExists( - atPath: claudeHomeURL.appendingPathComponent("history.jsonl").path - ) ? claudeHomeURL : nil) - guard let fallbackRoot else { return primary } - return "\(primary):\(claudeCodeChangeToken(claudeHomeURL: fallbackRoot))" - } - public func removeIndex(profileID: CodexProfile.ID) { Self.indexMutationLock.lock() defer { Self.indexMutationLock.unlock() } @@ -517,340 +239,28 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - public func loadTranscriptPage( - for session: LocalChatSession, - before cursor: LocalChatTranscriptCursor? = nil - ) -> LocalChatTranscriptPage { - guard !Task.isCancelled else { - return LocalChatTranscriptPage( - entries: [], - olderCursor: cursor, - sourceChanged: false, - malformedEventCount: 0 - ) - } - guard - let opened = openTranscriptSource(for: session) - else { - return LocalChatTranscriptPage( - entries: [ - .init( - id: "\(session.id)-missing", - kind: .error, - title: "Transcript unavailable", - text: "The local session file is no longer available." - ) - ], - olderCursor: nil, - sourceChanged: cursor != nil, - malformedEventCount: 0 - ) - } - let values = opened.values - let handle = opened.handle - defer { try? handle.close() } - - let currentSize = UInt64(max(0, values.fileSize ?? 0)) - let currentModifiedAt = values.contentModificationDate ?? session.sourceModifiedAt - let currentIdentifier = values.fileResourceIdentifier.map { String(describing: $0) } - if let cursor, cursor.sourceSize != currentSize - || cursor.sourceModifiedAt != currentModifiedAt - || (cursor.sourceIdentifier != nil && cursor.sourceIdentifier != currentIdentifier) - { - return LocalChatTranscriptPage( - entries: [], - olderCursor: nil, - sourceChanged: true, - malformedEventCount: 0 - ) - } - let snapshotSize = cursor?.sourceSize ?? currentSize - let endOffset = min(cursor?.byteOffset ?? snapshotSize, currentSize) - let sourceChanged = cursor.map { - $0.sourceSize != currentSize || $0.sourceModifiedAt != currentModifiedAt - } ?? (session.sourceSize != currentSize - || !Self.sameModificationDate(session.sourceModifiedAt, currentModifiedAt)) - - guard endOffset > 0 else { - return LocalChatTranscriptPage( - entries: [], - olderCursor: nil, - sourceChanged: sourceChanged, - malformedEventCount: 0 - ) - } - - let requestedStart = endOffset > UInt64(transcriptPageBytes) - ? endOffset - UInt64(transcriptPageBytes) - : 0 - guard let window = readRange(handle: handle, start: requestedStart, end: endOffset) else { - return LocalChatTranscriptPage( - entries: [], - olderCursor: nil, - sourceChanged: sourceChanged, - malformedEventCount: 0 - ) - } - - var completeStart = 0 - var olderOffset: UInt64? - var oversizedPrefixBytes: UInt64? - if requestedStart > 0 { - if let newline = window.firstIndex(of: 0x0A) { - completeStart = window.distance(from: window.startIndex, to: newline) + 1 - olderOffset = requestedStart + UInt64(completeStart - 1) - if completeStart == window.count { - let previous = previousNewlineOffset(handle: handle, before: requestedStart) - oversizedPrefixBytes = olderOffset.map { $0 - (previous.map { $0 + 1 } ?? 0) } - olderOffset = previous - } - } else { - let previous = previousNewlineOffset(handle: handle, before: requestedStart) - oversizedPrefixBytes = endOffset - (previous.map { $0 + 1 } ?? 0) - olderOffset = previous - completeStart = window.count - } - } - - var decoded: [(offset: UInt64, entry: LocalChatTranscriptEntry)] = [] - var malformedCount = 0 - if completeStart < window.count { - var lineStart = completeStart - var index = completeStart - while index <= window.count, !Task.isCancelled { - let isEnd = index == window.count - if isEnd || window[index] == 0x0A { - if index > lineStart { - let absoluteOffset = requestedStart + UInt64(lineStart) - let line = window.subdata(in: lineStart.. 0 { - let markerOffset = olderOffset.map { $0 + 1 } ?? 0 - decoded.insert(( - markerOffset, - .init( - id: "\(session.id)-oversized-\(markerOffset)", - kind: .oversized, - title: "Large history event", - text: "A \(Self.byteCount(oversizedPrefixBytes)) event is present in the local history and was not expanded in memory.", - isCollapsible: true - ) - ), at: 0) - } - - if decoded.count > transcriptPageEntries { - let boundary = decoded[decoded.count - transcriptPageEntries].offset - let firstBoundaryIndex = decoded.firstIndex { $0.offset == boundary } - ?? (decoded.count - transcriptPageEntries) - let kept = Array(decoded[firstBoundaryIndex...]) - olderOffset = kept.first?.offset - decoded = kept - } - let entries = decoded.map(\.entry) - let nextCursor = olderOffset.flatMap { offset -> LocalChatTranscriptCursor? in - guard offset > 0 else { return nil } - return LocalChatTranscriptCursor( - byteOffset: offset, - sourceSize: snapshotSize, - sourceModifiedAt: cursor?.sourceModifiedAt ?? currentModifiedAt, - sourceIdentifier: cursor?.sourceIdentifier ?? currentIdentifier - ) - } - return LocalChatTranscriptPage( - entries: entries, - olderCursor: nextCursor, - sourceChanged: sourceChanged, - malformedEventCount: malformedCount - ) - } - - /// Reads a bounded chronological page. The cursor points to the next byte - /// to inspect, so callers can append pages as the reader scrolls down. - public func loadTranscriptForwardPage( - for session: LocalChatSession, - after cursor: LocalChatTranscriptCursor? = nil - ) -> LocalChatTranscriptPage { - guard !Task.isCancelled, - let opened = openTranscriptSource(for: session) - else { - return .init(entries: [], olderCursor: nil, sourceChanged: cursor != nil, malformedEventCount: 0) - } - let values = opened.values - let handle = opened.handle - defer { try? handle.close() } - let size = UInt64(max(0, values.fileSize ?? 0)) - let modifiedAt = values.contentModificationDate ?? session.sourceModifiedAt - let identifier = values.fileResourceIdentifier.map { String(describing: $0) } - if let cursor, cursor.sourceSize != size - || cursor.sourceModifiedAt != modifiedAt - || (cursor.sourceIdentifier != nil && cursor.sourceIdentifier != identifier) - { - return .init(entries: [], olderCursor: nil, sourceChanged: true, malformedEventCount: 0) - } - let start = min(cursor?.byteOffset ?? 0, size) - guard start < size else { - return .init(entries: [], olderCursor: nil, sourceChanged: false, malformedEventCount: 0) - } - let end = min(size, start + UInt64(transcriptPageBytes)) - guard let window = readRange(handle: handle, start: start, end: end) else { - return .init(entries: [], olderCursor: nil, sourceChanged: false, malformedEventCount: 0) - } - - var entries: [LocalChatTranscriptEntry] = [] - var malformed = 0 - var lineStart = 0 - var skipping = cursor?.skippingOversizedLine == true - var sequence = cursor?.nextSequence ?? 0 - if skipping { - if let newline = window.firstIndex(of: 0x0A) { - lineStart = window.distance(from: window.startIndex, to: newline) + 1 - skipping = false - } - } - - var nextOffset = end - if !skipping { - var index = lineStart - while index <= window.count, entries.count < transcriptPageEntries, !Task.isCancelled { - let isWindowEnd = index == window.count - if isWindowEnd || window[index] == 0x0A { - if index > lineStart { - let isIncomplete = isWindowEnd && end < size - if isIncomplete { - if lineStart == 0 { - entries.append(.init( - id: "\(session.id)-oversized-\(start)", - kind: .oversized, - title: "Large history event", - text: "A history event larger than the page budget is present and was not expanded in memory.", - isCollapsible: true, - sourceOrdinal: sequence - )) - sequence += 1 - skipping = true - } else { - nextOffset = start + UInt64(lineStart) - } - break - } - let offset = start + UInt64(lineStart) - let line = window.subdata(in: lineStart.. LocalChatScanResult { - let inventory = claudeUserDataInventory(userDataURL: userDataURL, forStats: forStats) - guard !Task.isCancelled else { - return .init( - availability: .available, - sessions: [], - changeToken: inventory.changeToken - ) - } + let inventory = claudeUserDataInventory(userDataURL: userDataURL) + guard !Task.isCancelled else { return .init(sessions: []) } return .init( - availability: .available, sessions: inventory.records.map { record in LocalChatSession( id: record.id, provider: .claude, profileID: profileID, profileName: profileName, - title: record.title, - preview: record.preview, model: record.model, - repository: record.repository, - branch: nil, startedAt: record.startedAt, updatedAt: record.updatedAt, tokenCount: record.tokenCount, latestUsageLimit: record.latestUsageLimit, - status: record.status, - sourceURL: record.source.url, - sourceRootURL: userDataURL, - sourceSize: record.source.fileSize, - sourceModifiedAt: record.source.modifiedAt + status: record.status ) }, - changeToken: inventory.changeToken, diagnostics: .init( cacheHitCount: inventory.cacheHits, parsedFileCount: inventory.records.count - inventory.cacheHits, @@ -860,34 +270,23 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - private func claudeUserDataInventory(userDataURL: URL, forStats: Bool = false) -> ClaudeUserDataInventory { + private func claudeUserDataInventory(userDataURL: URL) -> ClaudeUserDataInventory { let metadataInventory = claudeMetadataCandidates(userDataURL: userDataURL) let candidates = metadataInventory.candidates.sorted { if $0.modifiedAt == $1.modifiedAt { return $0.relativePath < $1.relativePath } return $0.modifiedAt > $1.modifiedAt } var records: [ClaudeUserDataRecord] = [] - var tokenParts: [String] = [] var metadataBytes = 0 var remainingUsageBytes = maximumClaudeUsageBytes var cacheHits = 0 var truncated = metadataInventory.truncated for candidate in candidates { guard !Task.isCancelled else { break } - if !forStats { - tokenParts.append( - "\(candidate.relativePath)|\(candidate.fileSize)|\(candidate.modifiedAt.timeIntervalSince1970)" - ) - } let source = claudeAuditSourceFile( userDataURL: userDataURL, relativeMetadataPath: candidate.relativePath ) - if !forStats, let source { - tokenParts.append( - "\(source.relativePath)|\(source.fileSize)|\(source.modifiedAt.timeIntervalSince1970)" - ) - } guard candidate.fileSize <= maximumSummaryLineBytes, metadataBytes <= maximumMetadataBytes - candidate.fileSize, @@ -919,13 +318,10 @@ public struct LocalChatScanner: @unchecked Sendable { rawID: rawID, relativePath: candidate.relativePath ) - let titleText = forStats ? "" : Self.cleanTranscriptText(object["title"] as? String ?? "") - let prompt = forStats ? "" : Self.cleanTranscriptText(object["promptSuggestion"] as? String ?? "") let createdAt = Self.claudeDate(object["createdAt"]) ?? source.modifiedAt let updatedAt = Self.claudeDate(object["lastActivityAt"]) ?? Self.claudeDate(object["lastFocusedAt"]) ?? source.modifiedAt - let cwd = (object["cwd"] as? String) ?? (object["originCwd"] as? String) let usageResult = claudeUsageSummary( at: source, under: userDataURL, maximumBytes: remainingUsageBytes ) @@ -935,54 +331,22 @@ public struct LocalChatScanner: @unchecked Sendable { if usage == nil || usage?.isPartial == true { truncated = true } records.append(.init( id: id, - title: forStats ? "" : Self.title(from: titleText.isEmpty ? prompt : titleText), - preview: forStats ? nil : Self.preview(from: prompt.isEmpty ? titleText : prompt), model: Self.cleanMetadata(object["model"] as? String), - repository: forStats ? nil : Self.safeLastPathComponent(cwd), startedAt: createdAt, updatedAt: max(createdAt, updatedAt), tokenCount: usage?.totalTokens, latestUsageLimit: usage?.latestUsageLimit, - status: Self.boolValue(object["isArchived"]) ? "Archived" : "Unknown", - source: source + status: Self.boolValue(object["isArchived"]) ? "Archived" : "Unknown" )) } let bounded = boundedClaudeUserDataRecords(records) return .init( records: bounded, - changeToken: tokenParts.isEmpty - ? "" - : String( - Self.fnv1a64(tokenParts.sorted().joined(separator: "\n")), - radix: 16 - ), cacheHits: min(cacheHits, bounded.count), truncated: truncated || records.count > maximumSessions || Task.isCancelled ) } - private func claudeUserDataChangeToken(userDataURL: URL) -> String { - let candidates = claudeMetadataCandidates(userDataURL: userDataURL).candidates - var tokenParts: [String] = [] - tokenParts.reserveCapacity(min(maximumInventoryFiles * 2, 20_000)) - for candidate in candidates { - guard !Task.isCancelled else { break } - tokenParts.append( - "\(candidate.relativePath)|\(candidate.fileSize)|\(candidate.modifiedAt.timeIntervalSince1970)" - ) - if let source = claudeAuditSourceFile( - userDataURL: userDataURL, - relativeMetadataPath: candidate.relativePath - ) { - tokenParts.append( - "\(source.relativePath)|\(source.fileSize)|\(source.modifiedAt.timeIntervalSince1970)" - ) - } - } - guard !tokenParts.isEmpty else { return "" } - return String(Self.fnv1a64(tokenParts.sorted().joined(separator: "\n")), radix: 16) - } - private func claudeMetadataCandidates(userDataURL: URL) -> (candidates: [ClaudeMetadataCandidate], truncated: Bool) { let metadataRoot = userDataURL .appendingPathComponent("claude-code-sessions", isDirectory: true) @@ -1058,8 +422,7 @@ public struct LocalChatScanner: @unchecked Sendable { url: safe.url, relativePath: relativePath, fileSize: UInt64(safe.fileSize), - modifiedAt: safe.modifiedAt, - status: "Unknown" + modifiedAt: safe.modifiedAt ) } @@ -1178,15 +541,9 @@ public struct LocalChatScanner: @unchecked Sendable { .map { $0 } } - private func scanClaudeCode(claudeHomeURL: URL, forStats: Bool = false) -> LocalChatScanResult { - let inventory = claudeInventory(claudeHomeURL: claudeHomeURL, forStats: forStats) - guard !Task.isCancelled else { - return .init( - availability: .available, - sessions: [], - changeToken: inventory.changeToken - ) - } + private func scanClaudeCode(claudeHomeURL: URL) -> LocalChatScanResult { + let inventory = claudeInventory(claudeHomeURL: claudeHomeURL) + guard !Task.isCancelled else { return .init(sessions: []) } // History and per-session headers have independent bounded budgets. var remainingMetadataBytes = maximumMetadataBytes var metadataTruncated = false @@ -1203,8 +560,7 @@ public struct LocalChatScanner: @unchecked Sendable { let metadata = claudeBodyMetadata( at: source.url, under: claudeHomeURL, - maximumBytes: min(256 * 1_024, remainingMetadataBytes), - forStats: forStats + maximumBytes: min(256 * 1_024, remainingMetadataBytes) ) remainingMetadataBytes -= metadata.inspectedBytes metadataTruncated = metadataTruncated || metadata.skippedLines @@ -1214,25 +570,15 @@ public struct LocalChatScanner: @unchecked Sendable { provider: .claude, profileID: nil, profileName: "Official Claude", - title: forStats ? "" : Self.title(from: record.display), - preview: forStats ? nil : Self.preview(from: record.display), model: metadata.model, - repository: forStats ? nil : Self.safeLastPathComponent(metadata.cwd ?? record.project), - branch: forStats ? nil : metadata.branch, startedAt: record.startedAt ?? source.modifiedAt, updatedAt: record.updatedAt ?? source.modifiedAt, tokenCount: nil, - status: "Unknown", - sourceURL: source.url, - sourceRootURL: claudeHomeURL, - sourceSize: source.fileSize, - sourceModifiedAt: source.modifiedAt + status: "Unknown" ) } return .init( - availability: .available, sessions: sessions, - changeToken: inventory.changeToken, diagnostics: .init( cacheHitCount: inventory.cacheHit ? 1 : 0, parsedFileCount: inventory.inspectedBytes > 0 ? 1 : 0, @@ -1243,21 +589,20 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - private func claudeInventory(claudeHomeURL: URL, forStats: Bool = false) -> ClaudeInventory { + private func claudeInventory(claudeHomeURL: URL) -> ClaudeInventory { let home = claudeHomeURL.resolvingSymlinksInPath().standardizedFileURL let historyURL = home.appendingPathComponent("history.jsonl") guard let handle = openNoFollowRegularFile(at: historyURL, under: home) else { - return .init(records: [], changeToken: "") + return .init(records: []) } defer { try? handle.close() } guard let signature = fileSignature(handle) else { - return .init(records: [], changeToken: "", truncated: true) + return .init(records: [], truncated: true) } - let cacheKey = historyURL.path + (forStats ? "|stats" : "|presentation") + let cacheKey = historyURL.path if var cached = claudeHistoryCache.value(for: cacheKey, signature: signature) { cached.cacheHit = true cached.inspectedBytes = 0 - if forStats { cached.changeToken = "" } return cached } @@ -1265,7 +610,7 @@ public struct LocalChatScanner: @unchecked Sendable { let start = size > UInt64(maximumMetadataBytes) ? size - UInt64(maximumMetadataBytes) : 0 guard let window = readRange(handle: handle, start: start, end: size), window.count == Int(size - start) - else { return .init(records: [], changeToken: "", truncated: true) } + else { return .init(records: [], truncated: true) } // Drop the first boundary fragment and visit newest complete records first. let first = start == 0 ? 0 : window.firstIndex(of: 0x0A).map { $0 + 1 } ?? window.count var end = window.count @@ -1297,15 +642,11 @@ public struct LocalChatScanner: @unchecked Sendable { existing.startedAt = existing.startedAt.map { min($0, timestamp) } ?? timestamp existing.updatedAt = existing.updatedAt.map { max($0, timestamp) } ?? timestamp } - if existing.display.isEmpty, !forStats { - existing.display = String(Self.cleanTranscriptText(object["display"] as? String ?? "").prefix(32_768)) - } recordsByID[sessionID] = existing } else { guard recordsByID.count < maximumSessions else { truncated = true; continue } - let display = forStats ? "" : String(Self.cleanTranscriptText(object["display"] as? String ?? "").prefix(32_768)) recordsByID[sessionID] = .init( - id: sessionID, sourceSessionID: rawSessionID, project: project, display: display, + id: sessionID, sourceSessionID: rawSessionID, project: project, startedAt: timestamp, updatedAt: timestamp ) } @@ -1316,7 +657,6 @@ public struct LocalChatScanner: @unchecked Sendable { } let result = ClaudeInventory( records: records, - changeToken: forStats ? "" : claudeCodeChangeToken(claudeHomeURL: claudeHomeURL), truncated: truncated || Task.isCancelled, inspectedBytes: window.count ) @@ -1327,7 +667,6 @@ public struct LocalChatScanner: @unchecked Sendable { } var returned = result if !unchanged { returned.truncated = true } - if forStats { returned.changeToken = "" } return returned } @@ -1352,24 +691,6 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - private func claudeCodeChangeToken(claudeHomeURL: URL) -> String { - let home = claudeHomeURL.resolvingSymlinksInPath().standardizedFileURL - guard - let history = canonicalRegularFile( - url: home.appendingPathComponent("history.jsonl"), - under: home - ) - else { - return "" - } - return String( - Self.fnv1a64( - "history.jsonl|\(history.fileSize)|\(history.modifiedAt.timeIntervalSince1970)" - ), - radix: 16 - ) - } - private func claudeSourceFile( claudeHomeURL: URL, project: String, @@ -1400,16 +721,14 @@ public struct LocalChatScanner: @unchecked Sendable { url: safe.url, relativePath: relativePath, fileSize: UInt64(safe.fileSize), - modifiedAt: safe.modifiedAt, - status: "Unknown" + modifiedAt: safe.modifiedAt ) } private func claudeBodyMetadata( at url: URL, under root: URL, - maximumBytes: Int, - forStats: Bool = false + maximumBytes: Int ) -> ClaudeBodyMetadata { guard maximumBytes > 0 else { return .init() } var metadata = ClaudeBodyMetadata() @@ -1425,878 +744,19 @@ public struct LocalChatScanner: @unchecked Sendable { else { return inspectedBytes < 256 * 1_024 } - if !forStats { - metadata.cwd = Self.cleanMetadata(object["cwd"] as? String) ?? metadata.cwd - metadata.branch = Self.cleanMetadata(object["gitBranch"] as? String) ?? metadata.branch - } if let message = object["message"] as? [String: Any], let model = message["model"] as? String { metadata.model = Self.cleanMetadata(model) } - return inspectedBytes < 256 * 1_024 - && ((!forStats && metadata.branch == nil) || metadata.model == nil) + return inspectedBytes < 256 * 1_024 && metadata.model == nil } metadata.inspectedBytes = readResult.bytesRead metadata.skippedLines = readResult.skippedLines > 0 || !readResult.completed return metadata } - private func scanCodex( - codexHomeURL: URL, - profileID: CodexProfile.ID?, - profileName: String - ) -> LocalChatScanResult { - let canonicalHomeURL = codexHomeURL.resolvingSymlinksInPath().standardizedFileURL - let scopeKey = profileID.map { "managed-\($0.uuidString.lowercased())" } - ?? "official-\(String(Self.fnv1a64(codexHomeURL.standardizedFileURL.path), radix: 16))" - let sourceRootKey = String( - Self.fnv1a64(canonicalHomeURL.path), - radix: 16 - ) - let cached = loadIndex(scopeKey: scopeKey, sourceRootKey: sourceRootKey) - let cachedByPath = Dictionary(uniqueKeysWithValues: cached.records.map { - ($0.relativePath, $0) - }) - let databaseRows = indexedRows(codexHomeURL: codexHomeURL) - let inventory = sourceInventory(canonicalHomeURL: canonicalHomeURL) - guard inventory.rootsExist else { - return LocalChatScanResult( - availability: .available, - sessions: [], - changeToken: inventory.changeToken - ) - } - guard !Task.isCancelled else { - return LocalChatScanResult( - availability: .available, - sessions: [], - changeToken: inventory.changeToken - ) - } - let databaseByPath = Dictionary( - databaseRows.map { ($0.relativePath, $0) }, - uniquingKeysWith: { first, _ in first } - ) - var cacheHits = 0 - var parsedFiles = 0 - var remainingSummaryBytes = maximumMetadataBytes - var records: [ChatIndexRecord] = [] - let sources = boundedSources(inventory.files) - let sourcesByPath = Dictionary(uniqueKeysWithValues: sources.map { - ($0.relativePath, $0) - }) - - for source in sources { - guard !Task.isCancelled else { - return LocalChatScanResult( - availability: .available, - sessions: [], - changeToken: inventory.changeToken - ) - } - if let row = databaseByPath[source.relativePath] { - records.append(row.merging(source: source)) - cacheHits += cachedByPath[source.relativePath] == nil ? 0 : 1 - } else if - let existing = cachedByPath[source.relativePath], - existing.summaryComplete != false, - existing.fileSize == source.fileSize, - Self.sameModificationDate(existing.modifiedAt, source.modifiedAt), - existing.status == source.status - { - records.append(existing) - cacheHits += 1 - } else { - let summary = summaryRecord(source: source, maximumBytes: remainingSummaryBytes) - records.append(summary.record) - remainingSummaryBytes -= summary.bytesRead - if summary.bytesRead > 0 { parsedFiles += 1 } - } - } - - var seenIDs: Set = [] - records = records.filter { seenIDs.insert($0.id).inserted } - records = boundedRecords(records) - let document = ChatIndexDocument( - version: Self.indexVersion, - scopeKey: scopeKey, - sourceRootKey: sourceRootKey, - records: records - ) - if document != cached { - saveIndex(document, scopeKey: scopeKey) - } - - let sessions = records.compactMap { record -> LocalChatSession? in - guard let source = sourcesByPath[record.relativePath] else { return nil } - return session( - record: record, - source: source, - codexHomeURL: canonicalHomeURL, - profileID: profileID, - profileName: profileName - ) - } - return LocalChatScanResult( - availability: .available, - sessions: sessions, - changeToken: inventory.changeToken, - diagnostics: .init( - cacheHitCount: cacheHits, - parsedFileCount: parsedFiles, - sourceFileCount: inventory.files.count, - usedDatabase: !databaseRows.isEmpty, - inventoryTruncated: inventory.truncated - ) - ) - } - - private func sourceInventory(canonicalHomeURL: URL) -> SourceInventory { - let roots = [ - (canonicalHomeURL.appendingPathComponent("sessions", isDirectory: true), "Unknown"), - (canonicalHomeURL.appendingPathComponent("archived_sessions", isDirectory: true), "Archived") - ] - let homePrefix = canonicalHomeURL.path + "/" - let keys: Set = [ - .isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey - ] - var files: [SourceFile] = [] - var tokenParts: [String] = [] - var rootsExist = false - var inspectedEntries = 0 - var truncated = false - - inventory: for (root, status) in roots where fileManager.fileExists(atPath: root.path) { - rootsExist = true - let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL - guard canonicalRoot.path == root.path else { continue } - let rootPrefix = canonicalRoot.path + "/" - guard let enumerator = fileManager.enumerator( - at: canonicalRoot, - includingPropertiesForKeys: Array(keys), - options: [.skipsHiddenFiles, .skipsPackageDescendants] - ) else { - continue - } - for case let url as URL in enumerator { - guard !Task.isCancelled else { break inventory } - guard inspectedEntries < maximumInventoryFiles else { - truncated = true - break inventory - } - inspectedEntries += 1 - guard url.pathExtension == "jsonl" else { continue } - guard - let values = try? url.resourceValues(forKeys: keys), - values.isRegularFile == true, - values.isSymbolicLink != true, - let fileSize = values.fileSize, - fileSize >= 0 - else { - continue - } - let standardized = url.standardizedFileURL - let canonicalURL = standardized.resolvingSymlinksInPath().standardizedFileURL - guard - canonicalURL.path == standardized.path, - canonicalURL.path.hasPrefix(rootPrefix), - canonicalURL.path.hasPrefix(homePrefix) - else { - continue - } - let relativePath = String(canonicalURL.path.dropFirst(homePrefix.count)) - let size = UInt64(fileSize) - let modifiedAt = values.contentModificationDate ?? .distantPast - files.append(.init( - url: canonicalURL, - relativePath: relativePath, - fileSize: size, - modifiedAt: modifiedAt, - status: status - )) - tokenParts.append("\(relativePath)|\(size)|\(modifiedAt.timeIntervalSince1970)") - } - } - for name in ["state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm"] { - let url = canonicalHomeURL.appendingPathComponent(name) - if let values = try? url.resourceValues( - forKeys: [.fileSizeKey, .contentModificationDateKey] - ) { - tokenParts.append( - "\(name)|\(values.fileSize ?? 0)|\(values.contentModificationDate?.timeIntervalSince1970 ?? 0)" - ) - } - } - files.sort { $0.relativePath < $1.relativePath } - tokenParts.sort() - return SourceInventory( - rootsExist: rootsExist, - files: files, - changeToken: String(Self.fnv1a64(tokenParts.joined(separator: "\n")), radix: 16), - truncated: truncated - ) - } - - private func sourceChangeToken(codexHomeURL: URL) -> String { - let database = codexHomeURL.appendingPathComponent("state_5.sqlite") - guard fileManager.fileExists(atPath: database.path) else { - return sourceInventory( - canonicalHomeURL: codexHomeURL.resolvingSymlinksInPath().standardizedFileURL - ).changeToken - } - var parts: [String] = [] - for name in [ - "state_5.sqlite", "state_5.sqlite-wal", - "sessions", "archived_sessions" - ] { - let url = codexHomeURL.appendingPathComponent(name) - if let values = try? url.resourceValues( - forKeys: [.fileSizeKey, .contentModificationDateKey] - ) { - parts.append( - "\(name)|\(values.fileSize ?? 0)|\(values.contentModificationDate?.timeIntervalSince1970 ?? 0)" - ) - } - } - return String(Self.fnv1a64(parts.sorted().joined(separator: "\n")), radix: 16) - } - - private func summaryRecord( - source: SourceFile, - maximumBytes: Int - ) -> (record: ChatIndexRecord, bytesRead: Int) { - var sessionID: String? - var firstUserMessage: String? - var startedAt: Date? - var updatedAt = source.modifiedAt - var repository: String? - var branch: String? - var model: String? - var tokenCount: Int? - var status = source.status - let sourceBudget = min(maximumSummaryLineBytes, maximumMetadataBytes) - - let readResult = forEachForwardLine( - at: source.url, - maximumBytes: min(sourceBudget, maximumBytes) - ) { line in - guard - let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], - let type = object["type"] as? String, - let payload = object["payload"] as? [String: Any] - else { - return true - } - let eventDate = Self.eventDate(object: object, payload: payload) - if let eventDate { - startedAt = startedAt.map { min($0, eventDate) } ?? eventDate - updatedAt = max(updatedAt, eventDate) - } - switch type { - case "session_meta": - sessionID = (payload["id"] as? String) - ?? (payload["session_id"] as? String) - ?? sessionID - if let cwd = payload["cwd"] as? String { - repository = Self.safeLastPathComponent(cwd) - } - if let git = payload["git"] as? [String: Any] { - branch = Self.cleanMetadata(git["branch"] as? String) - } - case "turn_context": - model = Self.cleanMetadata(payload["model"] as? String) ?? model - if repository == nil, let cwd = payload["cwd"] as? String { - repository = Self.safeLastPathComponent(cwd) - } - case "response_item": - if - payload["type"] as? String == "message", - payload["role"] as? String == LocalChatRole.user.rawValue, - let text = Self.messageText(payload) - { - let visible = Self.visibleMessageText(text, role: .user) - if !visible.isEmpty { firstUserMessage = visible } - } - case "event_msg": - switch payload["type"] as? String { - case "token_count": - if - let info = payload["info"] as? [String: Any], - let usage = info["total_token_usage"] as? [String: Any] - { - tokenCount = usage["total_tokens"] as? Int ?? tokenCount - } - case "task_complete": status = "Completed" - case "task_started": status = "In progress" - case "task_failed": status = "Failed" - default: break - } - default: - break - } - return firstUserMessage == nil - } - - let safeID = Self.cleanIdentifier(sessionID) - ?? "local-\(String(Self.fnv1a64(source.relativePath), radix: 16))" - let title = Self.title(from: firstUserMessage) - let record = ChatIndexRecord( - relativePath: source.relativePath, - fileSize: source.fileSize, - modifiedAt: source.modifiedAt, - id: safeID, - title: title, - preview: Self.preview(from: firstUserMessage), - model: model, - repository: repository, - branch: branch, - startedAt: startedAt ?? source.modifiedAt, - updatedAt: updatedAt, - tokenCount: tokenCount, - status: status, - summaryComplete: firstUserMessage != nil - || maximumBytes >= min(sourceBudget, Int(clamping: source.fileSize)) - ) - return (record, readResult.bytesRead) - } - - private func indexedRows(codexHomeURL: URL) -> [ChatIndexRecord] { - let database = codexHomeURL.appendingPathComponent("state_5.sqlite") - guard fileManager.isReadableFile(atPath: database.path) else { return [] } - guard let columns = threadColumns(database: database), columns.contains("rollout_path") else { - return [] - } - func expression(_ column: String, fallback: String) -> String { - columns.contains(column) ? column : fallback - } - let orderColumn = [ - "recency_at_ms", "updated_at_ms", "updated_at", "created_at_ms", "created_at" - ].first(where: columns.contains) ?? "rowid" - let createdExpression = expression( - columns.contains("created_at_ms") ? "created_at_ms" : "created_at", - fallback: "0" - ) - let updatedExpression = expression( - columns.contains("updated_at_ms") ? "updated_at_ms" : "updated_at", - fallback: createdExpression - ) - let titleCandidates = ["title", "name", "first_user_message", "preview"] - .filter(columns.contains) - .map { "NULLIF(\($0), '')" } - let titleExpression = titleCandidates.isEmpty - ? "''" - : "COALESCE(\(titleCandidates.joined(separator: ", ")), '')" - let statusPartition = columns.contains("archived") - ? "CASE WHEN archived IS NOT NULL AND archived != 0 THEN 1 ELSE 0 END" - : "0" - let query = """ - PRAGMA query_only=ON; - WITH ranked AS ( - SELECT - \(expression("id", fallback: "''")) AS id, - \(expression("rollout_path", fallback: "''")) AS rollout_path, - \(createdExpression) AS created_at, - \(updatedExpression) AS updated_at, - \(expression("cwd", fallback: "''")) AS cwd, - \(titleExpression) AS title, - \(expression("tokens_used", fallback: "NULL")) AS tokens_used, - \(expression("archived", fallback: "0")) AS archived, - \(expression("git_branch", fallback: "NULL")) AS git_branch, - \(expression("model", fallback: "NULL")) AS model, - ROW_NUMBER() OVER ( - PARTITION BY \(statusPartition) - ORDER BY \(orderColumn) DESC - ) AS status_rank - FROM threads - ) - SELECT id, rollout_path, created_at, updated_at, cwd, title, - tokens_used, archived, git_branch, model - FROM ranked - WHERE status_rank <= \(maximumSessions) - ORDER BY updated_at DESC - LIMIT \(maximumSessions + max(1, maximumSessions / 4)); - """ - guard - let databaseArgument = try? SQLiteReadOnly.databaseArgument(for: database), - let result = try? BoundedSubprocess.run( - executableURL: Self.sqliteURL, - arguments: [ - "-nofollow", "-readonly", "-json", - databaseArgument, - query - ], - timeout: 3, - maximumOutputBytes: 8 * 1_024 * 1_024, - captureStandardError: true - ), - result.terminationStatus == 0, - !result.exceededOutputLimit, - !Task.isCancelled, - let values = try? JSONSerialization.jsonObject(with: result.output) as? [[String: Any]] - else { - return [] - } - return values.compactMap { row in - guard - let rawPath = row["rollout_path"] as? String, - let sourceURL = safeSessionURL(path: rawPath, codexHomeURL: codexHomeURL), - let relativePath = relativePath(for: sourceURL, under: codexHomeURL), - let source = sourceFile( - url: sourceURL, - relativePath: relativePath, - status: relativePath.hasPrefix("archived_sessions/") - || Self.boolValue(row["archived"]) ? "Archived" : "Unknown" - ) - else { - return nil - } - let rawTitle = row["title"] as? String - let visibleTitle = Self.visibleMessageText(rawTitle ?? "", role: .user) - let createdAt = Self.date(fromSQLite: row["created_at"]) ?? source.modifiedAt - let updatedAt = Self.date(fromSQLite: row["updated_at"]) ?? source.modifiedAt - return ChatIndexRecord( - relativePath: relativePath, - fileSize: source.fileSize, - modifiedAt: source.modifiedAt, - id: Self.cleanIdentifier(row["id"] as? String) - ?? "local-\(String(Self.fnv1a64(relativePath), radix: 16))", - title: Self.title(from: visibleTitle), - preview: Self.preview(from: visibleTitle), - model: Self.cleanMetadata(row["model"] as? String), - repository: Self.safeLastPathComponent(row["cwd"] as? String), - branch: Self.cleanMetadata(row["git_branch"] as? String), - startedAt: createdAt, - updatedAt: updatedAt, - tokenCount: Self.intValue(row["tokens_used"]), - status: source.status, - summaryComplete: true - ) - } - } - - private func threadColumns(database: URL) -> Set? { - guard - let databaseArgument = try? SQLiteReadOnly.databaseArgument(for: database), - let result = try? BoundedSubprocess.run( - executableURL: Self.sqliteURL, - arguments: [ - "-nofollow", "-readonly", "-json", - databaseArgument, - "PRAGMA table_info(threads);" - ], - timeout: 2, - maximumOutputBytes: 256 * 1_024, - captureStandardError: true - ), - result.terminationStatus == 0, - let rows = try? JSONSerialization.jsonObject(with: result.output) as? [[String: Any]] - else { - return nil - } - return Set(rows.compactMap { $0["name"] as? String }) - } - - private func transcriptEntries( - line: Data, - sessionID: String, - byteOffset: UInt64, - provider: DesktopProduct - ) -> [LocalChatTranscriptEntry] { - if line.count > maximumRenderableEntryBytes { - return [.init( - id: "\(sessionID)-oversized-\(byteOffset)", - kind: .oversized, - title: "Large history event", - text: "A \(Self.byteCount(UInt64(line.count))) event is present and was not expanded in memory.", - isCollapsible: true - )] - } - switch provider { - case .codex: - return codexTranscriptEntry( - line: line, - sessionID: sessionID, - byteOffset: byteOffset - ).map { [$0] } ?? [] - case .claude: - return claudeTranscriptEntries( - line: line, - sessionID: sessionID, - byteOffset: byteOffset - ) - } - } - - private func codexTranscriptEntry( - line: Data, - sessionID: String, - byteOffset: UInt64 - ) -> LocalChatTranscriptEntry? { - guard - let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], - let type = object["type"] as? String, - let payload = object["payload"] as? [String: Any] - else { - return nil - } - let id = "\(sessionID)-\(byteOffset)" - let timestamp = Self.eventDate(object: object, payload: payload) - switch type { - case "response_item": - let itemType = payload["type"] as? String - if - itemType == "message", - let rawRole = payload["role"] as? String, - let role = LocalChatRole(rawValue: rawRole), - let rawText = Self.messageText(payload) - { - let text = Self.visibleMessageText(rawText, role: role) - guard !text.isEmpty else { return nil } - return .init( - id: id, - kind: .message, - role: role, - text: text, - timestamp: timestamp - ) - } - if itemType == "reasoning" { - let text = Self.payloadText(payload) - guard !text.isEmpty else { return nil } - return .init( - id: id, - kind: .reasoning, - title: "Reasoning", - text: text, - timestamp: timestamp, - isCollapsible: true - ) - } - if itemType == "function_call" || itemType == "custom_tool_call" { - let name = Self.cleanMetadata(payload["name"] as? String) ?? "Tool" - let arguments = Self.privateToolNames.contains(name.lowercased()) - ? "Private agent coordination payload omitted." - : Self.cleanToolText( - (payload["arguments"] as? String) - ?? (payload["input"] as? String) - ?? "" - ) - return .init( - id: id, - kind: Self.commandLike(name) ? .command : .activity, - title: name, - text: arguments.isEmpty ? "Started" : arguments, - timestamp: timestamp, - isCollapsible: true - ) - } - if itemType == "function_call_output" || itemType == "custom_tool_call_output" { - let output = Self.cleanToolText( - (payload["output"] as? String) ?? Self.payloadText(payload) - ) - let isError = (payload["success"] as? Bool) == false - || output.localizedCaseInsensitiveContains("error") - return .init( - id: id, - kind: isError ? .error : .activity, - title: isError ? "Tool error" : "Tool result", - text: output.isEmpty ? "Completed" : output, - timestamp: timestamp, - isCollapsible: true - ) - } - guard itemType != "message" else { return nil } - return Self.unsupportedCodexEntry( - id: id, - type: itemType ?? "response item", - timestamp: timestamp - ) - case "event_msg": - let eventType = payload["type"] as? String ?? "Activity" - switch eventType { - case "agent_reasoning", "reasoning": - return .init( - id: id, - kind: .reasoning, - title: "Reasoning", - text: Self.payloadText(payload), - timestamp: timestamp, - isCollapsible: true - ) - case "task_started": - return .init(id: id, kind: .status, title: "Started", text: "", timestamp: timestamp) - case "task_complete": - return .init(id: id, kind: .status, title: "Completed", text: "", timestamp: timestamp) - case "task_failed", "error": - return .init( - id: id, - kind: .error, - title: "Error", - text: Self.payloadText(payload), - timestamp: timestamp, - isCollapsible: true - ) - case "token_count": - return nil - default: - let text = Self.payloadText(payload) - guard !text.isEmpty else { return nil } - return .init( - id: id, - kind: .activity, - title: Self.humanized(eventType), - text: text, - timestamp: timestamp, - isCollapsible: true - ) - } - case "session_meta", "turn_context", "world_state", "token_usage_record": - // Usage records accompany transcript items in current Codex rollouts; - // they are accounting metadata, not an unsupported conversation item. - return nil - default: - return Self.unsupportedCodexEntry(id: id, type: type, timestamp: timestamp) - } - } - - private static func unsupportedCodexEntry( - id: String, - type: String, - timestamp: Date? - ) -> LocalChatTranscriptEntry { - .init( - id: id, - kind: .unsupported, - title: "Unsupported event", - text: "A \(humanized(type)) history event is not supported by this AgentDock version.", - timestamp: timestamp, - isCollapsible: true - ) - } - - private func claudeTranscriptEntries( - line: Data, - sessionID: String, - byteOffset: UInt64 - ) -> [LocalChatTranscriptEntry] { - guard - let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], - let type = object["type"] as? String - else { - return [] - } - let rawEventID = object["uuid"] as? String - let eventComponent = Self.unchangedValidIdentifier(rawEventID) - ?? "derived-\(String(Self.fnv1a64(rawEventID ?? ""), radix: 16))" - let eventID = "\(sessionID)-\(byteOffset)-\(eventComponent)" - let timestamp = Self.date(object["_audit_timestamp"]) - ?? Self.date(object["timestamp"]) - let message = object["message"] as? [String: Any] - - switch type { - case "user", "assistant": - let role: LocalChatRole = type == "user" ? .user : .assistant - var entries: [LocalChatTranscriptEntry] = [] - if - type == "assistant", - Self.boolValue(object["isApiErrorMessage"]) - || (object["error"] != nil && !(object["error"] is NSNull)) - { - entries.append(.init( - id: "\(eventID)-error", - kind: .error, - title: "Error", - text: Self.claudeErrorText(object["error"]), - timestamp: timestamp, - isCollapsible: true - )) - } - guard let content = message?["content"] else { - return entries.isEmpty - ? [Self.unsupportedClaudeEntry( - id: eventID, - type: type, - timestamp: timestamp - )] - : entries - } - if let text = content as? String { - let cleaned = Self.visibleMessageText(text, role: role) - if !cleaned.isEmpty { - entries.append(.init( - id: eventID, - kind: .message, - role: role, - text: cleaned, - timestamp: timestamp - )) - } - return entries - } - guard let blocks = content as? [[String: Any]] else { - entries.append(Self.unsupportedClaudeEntry( - id: eventID, - type: "\(type) content", - timestamp: timestamp - )) - return entries - } - for (index, block) in blocks.prefix(transcriptPageEntries).enumerated() { - let id = "\(eventID)-\(index)" - let blockType = block["type"] as? String ?? "unknown" - switch blockType { - case "text": - let text = Self.visibleMessageText(block["text"] as? String ?? "", role: role) - if !text.isEmpty { - entries.append(.init( - id: id, - kind: .message, - role: role, - text: text, - timestamp: timestamp - )) - } - case "thinking": - let text = Self.cleanTranscriptText( - (block["thinking"] as? String) ?? (block["text"] as? String) ?? "" - ) - entries.append(.init( - id: id, - kind: .reasoning, - title: "Reasoning", - text: text.isEmpty ? "Reasoning content unavailable." : text, - timestamp: timestamp, - isCollapsible: true - )) - case "tool_use": - let name = Self.cleanMetadata(block["name"] as? String) ?? "Tool" - let input = Self.privateToolNames.contains(name.lowercased()) - ? "Private agent coordination payload omitted." - : Self.cleanToolText(Self.jsonText(block["input"])) - entries.append(.init( - id: id, - kind: Self.commandLike(name) ? .command : .activity, - title: name, - text: input.isEmpty ? "Started" : input, - timestamp: timestamp, - isCollapsible: true - )) - case "tool_result": - let text = Self.cleanToolText(Self.claudeContentText(block["content"])) - let isError = Self.boolValue(block["is_error"]) - entries.append(.init( - id: id, - kind: isError ? .error : .activity, - title: isError ? "Tool error" : "Tool result", - text: text.isEmpty ? (isError ? "Failed" : "Completed") : text, - timestamp: timestamp, - isCollapsible: true - )) - default: - entries.append(Self.unsupportedClaudeEntry( - id: id, - type: "\(type) \(blockType)", - timestamp: timestamp - )) - } - } - if blocks.count > transcriptPageEntries { - entries.append(Self.unsupportedClaudeEntry( - id: "\(eventID)-truncated", - type: "additional content blocks", - timestamp: timestamp - )) - } - return entries - case "system": - let subtype = Self.cleanMetadata(object["subtype"] as? String) ?? "system" - let isError = subtype.localizedCaseInsensitiveContains("error") - || Self.boolValue(object["isApiErrorMessage"]) - || (object["error"] != nil && !(object["error"] is NSNull)) - if let name = Self.cleanMetadata(object["tool_name"] as? String) { - let input = Self.cleanToolText(Self.jsonText(object["tool_input"])) - return [.init( - id: eventID, - kind: Self.commandLike(name) ? .command : .activity, - title: name, - text: input.isEmpty ? Self.humanized(subtype) : input, - timestamp: timestamp, - isCollapsible: true - )] - } - return [.init( - id: eventID, - kind: isError ? .error : .status, - title: Self.humanized(subtype), - text: isError - ? Self.claudeErrorText(object["error"]) - : Self.cleanTranscriptText(object["message"] as? String ?? ""), - timestamp: timestamp, - isCollapsible: isError - )] - case "mode", "permission-mode": - let value = (object["mode"] as? String) - ?? (object["permissionMode"] as? String) - ?? "Updated" - return [.init( - id: eventID, - kind: .status, - title: Self.humanized(type), - text: Self.cleanTranscriptText(value), - timestamp: timestamp - )] - case "tool_use_summary": - return [.init( - id: eventID, - kind: .activity, - title: "Tool summary", - text: Self.cleanTranscriptText(object["summary"] as? String ?? ""), - timestamp: timestamp, - isCollapsible: true - )] - case "result": - let isError = Self.boolValue(object["is_error"]) - || (object["subtype"] as? String)?.localizedCaseInsensitiveContains("error") == true - let text = Self.cleanToolText( - (object["result"] as? String) - ?? (object["terminal_reason"] as? String) - ?? "" - ) - return [.init( - id: eventID, - kind: isError ? .error : .status, - title: isError ? "Error" : "Completed", - text: text, - timestamp: timestamp, - isCollapsible: isError - )] - case "rate_limit_event": - return [.init( - id: eventID, - kind: .status, - title: "Rate limit", - text: "Rate limit status updated.", - timestamp: timestamp - )] - default: - return [Self.unsupportedClaudeEntry(id: eventID, type: type, timestamp: timestamp)] - } - } - - private func isValidHistoryEnvelope(_ line: Data, provider: DesktopProduct) -> Bool { - guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { - return false - } - switch provider { - case .codex: - return object["type"] is String && object["payload"] is [String: Any] - case .claude: - return object["type"] is String - } - } - private func forEachForwardLine( at url: URL, under root: URL? = nil, @@ -2388,73 +848,6 @@ public struct LocalChatScanner: @unchecked Sendable { return data } - private func previousNewlineOffset(handle: FileHandle, before offset: UInt64) -> UInt64? { - var cursor = offset - let lowerBound = offset > UInt64(maximumRenderableEntryBytes) - ? offset - UInt64(maximumRenderableEntryBytes) - : 0 - while cursor > lowerBound, !Task.isCancelled { - let start = cursor > 64 * 1_024 ? cursor - 64 * 1_024 : 0 - let boundedStart = max(start, lowerBound) - guard let data = readRange(handle: handle, start: boundedStart, end: cursor) else { - return nil - } - if let index = data.lastIndex(of: 0x0A) { - return boundedStart + UInt64(data.distance(from: data.startIndex, to: index)) - } - cursor = boundedStart - } - return lowerBound > 0 ? lowerBound : nil - } - - private func transcriptSourceValues(for session: LocalChatSession) -> URLResourceValues? { - guard - let safe = canonicalRegularFile( - url: session.sourceURL, - under: session.sourceRootURL - ) - else { - return nil - } - let allowed: Bool - switch session.provider { - case .codex: - allowed = safe.relativePath.hasPrefix("sessions/") - || safe.relativePath.hasPrefix("archived_sessions/") - case .claude: - allowed = safe.relativePath.hasPrefix("local-agent-mode-sessions/") - || safe.relativePath.hasPrefix("projects/") - } - guard - allowed, - safe.url.path == session.sourceURL.standardizedFileURL.path - else { - return nil - } - return try? safe.url.resourceValues(forKeys: [ - .fileSizeKey, .contentModificationDateKey, - .isRegularFileKey, .isSymbolicLinkKey, - .fileResourceIdentifierKey - ]) - } - - private func openTranscriptSource(for session: LocalChatSession) -> OpenedTranscriptSource? { - guard - let before = transcriptSourceValues(for: session), - let handle = openNoFollowRegularFile( - at: session.sourceURL, - under: session.sourceRootURL - ), - let after = transcriptSourceValues(for: session), - before.fileSize == after.fileSize, - before.fileResourceIdentifier.map({ String(describing: $0) }) - == after.fileResourceIdentifier.map({ String(describing: $0) }) - else { - return nil - } - return .init(handle: handle, values: after) - } - private func openNoFollowRegularFile(at url: URL, under root: URL? = nil) -> FileHandle? { if let root, canonicalRegularFile(url: url, under: root) == nil { return nil @@ -2478,24 +871,6 @@ public struct LocalChatScanner: @unchecked Sendable { return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) } - private func sourceFile(url: URL, relativePath: String, status: String) -> SourceFile? { - guard - let values = try? url.resourceValues( - forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] - ), - values.isRegularFile == true - else { - return nil - } - return SourceFile( - url: url, - relativePath: relativePath, - fileSize: UInt64(max(0, values.fileSize ?? 0)), - modifiedAt: values.contentModificationDate ?? .distantPast, - status: status - ) - } - private func canonicalRegularFile( url: URL, under root: URL @@ -2526,89 +901,6 @@ public struct LocalChatScanner: @unchecked Sendable { ) } - private func session( - record: ChatIndexRecord, - source: SourceFile, - codexHomeURL: URL, - profileID: CodexProfile.ID?, - profileName: String - ) -> LocalChatSession { - return LocalChatSession( - id: record.id, - provider: .codex, - profileID: profileID, - profileName: profileName, - title: record.title, - preview: record.preview, - model: record.model, - repository: record.repository, - branch: record.branch, - startedAt: record.startedAt, - updatedAt: record.updatedAt, - tokenCount: record.tokenCount, - status: record.status, - sourceURL: source.url, - sourceRootURL: codexHomeURL, - sourceSize: source.fileSize, - sourceModifiedAt: source.modifiedAt - ) - } - - private func boundedRecords(_ records: [ChatIndexRecord]) -> [ChatIndexRecord] { - let sorted = records.sorted { - if $0.updatedAt == $1.updatedAt { return $0.id > $1.id } - return $0.updatedAt > $1.updatedAt - } - guard sorted.count > maximumSessions else { return sorted } - let archived = sorted.filter { $0.status == "Archived" } - let active = sorted.filter { $0.status != "Archived" } - let archiveCapacity = min(archived.count, max(1, maximumSessions / 4)) - let activeCapacity = maximumSessions - archiveCapacity - var kept = Array(active.prefix(activeCapacity)) + archived.prefix(archiveCapacity) - if kept.count < maximumSessions { - let keptIDs = Set(kept.map(\.id)) - kept += sorted.filter { !keptIDs.contains($0.id) }.prefix(maximumSessions - kept.count) - } - return kept.sorted { - if $0.updatedAt == $1.updatedAt { return $0.id > $1.id } - return $0.updatedAt > $1.updatedAt - } - } - - private func boundedSources(_ sources: [SourceFile]) -> [SourceFile] { - guard sources.count > maximumSessions else { return sources } - let sorted = sources.sorted { - if $0.modifiedAt == $1.modifiedAt { return $0.relativePath > $1.relativePath } - return $0.modifiedAt > $1.modifiedAt - } - let archived = sorted.filter { $0.status == "Archived" } - let active = sorted.filter { $0.status != "Archived" } - let archiveCapacity = min(archived.count, max(1, maximumSessions / 4)) - let activeCapacity = maximumSessions - archiveCapacity - var kept = Array(active.prefix(activeCapacity)) + archived.prefix(archiveCapacity) - if kept.count < maximumSessions { - let paths = Set(kept.map(\.relativePath)) - kept += sorted.filter { !paths.contains($0.relativePath) } - .prefix(maximumSessions - kept.count) - } - return kept - } - - private func safeSessionURL(path: String, codexHomeURL: URL) -> URL? { - let candidate = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL - let home = codexHomeURL.resolvingSymlinksInPath().standardizedFileURL - let allowedRoots = ["sessions", "archived_sessions"].map { - home.appendingPathComponent($0, isDirectory: true).path + "/" - } - guard - allowedRoots.contains(where: { candidate.path.hasPrefix($0) }), - (try? candidate.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true - else { - return nil - } - return candidate - } - private func relativePath(for url: URL, under root: URL) -> String? { let base = root.resolvingSymlinksInPath().standardizedFileURL.path + "/" let path = url.resolvingSymlinksInPath().standardizedFileURL.path @@ -2616,79 +908,12 @@ public struct LocalChatScanner: @unchecked Sendable { return String(path.dropFirst(base.count)) } - private func loadIndex(scopeKey: String, sourceRootKey: String) -> ChatIndexDocument { - let url = indexURL(scopeKey: scopeKey) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .millisecondsSince1970 - guard - let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), - values.isRegularFile == true, - let size = values.fileSize, - size >= 0, size <= 8 * 1_024 * 1_024, - let data = try? BoundedFileReader.data( - at: url, - maximumBytes: LocalControlFileLimit.chatIndex - ), - let document = try? decoder.decode(ChatIndexDocument.self, from: data), - document.version == Self.indexVersion, - document.scopeKey == scopeKey, - document.sourceRootKey == sourceRootKey, - document.records.count <= maximumSessions, - Set(document.records.map(\.relativePath)).count == document.records.count, - document.records.allSatisfy(Self.isValidCachedRecord) - else { - return ChatIndexDocument( - version: Self.indexVersion, - scopeKey: scopeKey, - sourceRootKey: sourceRootKey, - records: [] - ) - } - return document - } - - private func saveIndex(_ document: ChatIndexDocument, scopeKey: String) { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .millisecondsSince1970 - encoder.outputFormatting = [.sortedKeys] - guard !Task.isCancelled, let data = try? encoder.encode(document) else { return } - Self.indexMutationLock.lock() - defer { Self.indexMutationLock.unlock() } - guard !Task.isCancelled else { return } - let url = indexURL(scopeKey: scopeKey) - do { - try fileManager.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try fileManager.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: url.deletingLastPathComponent().path - ) - try data.write(to: url, options: [.atomic]) - try fileManager.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: url.path - ) - } catch { - // A read-only profile remains usable; it simply loses the incremental cache. - } - } - private func indexURL(scopeKey: String) -> URL { indexRootURL.appendingPathComponent("\(scopeKey)-v2.json") } - private struct SourceInventory { - let rootsExist: Bool - let files: [SourceFile] - let changeToken: String - let truncated: Bool - } - private struct ClaudeInventory { let records: [ClaudeHistoryRecord] - var changeToken: String var truncated = false var inspectedBytes = 0 var cacheHit = false @@ -2717,30 +942,24 @@ public struct LocalChatScanner: @unchecked Sendable { private struct ClaudeUserDataInventory { let records: [ClaudeUserDataRecord] - let changeToken: String let cacheHits: Int let truncated: Bool } private struct ClaudeUserDataRecord { let id: String - let title: String - let preview: String? let model: String? - let repository: String? let startedAt: Date let updatedAt: Date let tokenCount: Int? let latestUsageLimit: UsageLimitSignal? let status: String - let source: SourceFile } private struct ClaudeHistoryRecord { let id: String let sourceSessionID: String var project: String - var display: String var startedAt: Date? var updatedAt: Date? } @@ -2759,14 +978,7 @@ public struct LocalChatScanner: @unchecked Sendable { let modifiedAt: Date } - private struct OpenedTranscriptSource { - let handle: FileHandle - let values: URLResourceValues - } - private struct ClaudeBodyMetadata { - var cwd: String? - var branch: String? var model: String? var inspectedBytes = 0 var skippedLines = false @@ -2777,87 +989,6 @@ public struct LocalChatScanner: @unchecked Sendable { let relativePath: String let fileSize: UInt64 let modifiedAt: Date - let status: String - } - - private struct ChatIndexDocument: Codable, Equatable { - let version: Int - let scopeKey: String - let sourceRootKey: String? - let records: [ChatIndexRecord] - } - - private static func isValidCachedRecord(_ record: ChatIndexRecord) -> Bool { - record.relativePath.count <= 4_096 - && record.id.count <= 1_024 - && record.title.count <= 8_192 - && (record.preview?.count ?? 0) <= 32_768 - && (record.model?.count ?? 0) <= 1_024 - && (record.repository?.count ?? 0) <= 4_096 - && (record.branch?.count ?? 0) <= 4_096 - && (record.relativePath.hasPrefix("sessions/") - || record.relativePath.hasPrefix("archived_sessions/")) - } - - private struct ChatIndexRecord: Codable, Equatable { - let relativePath: String - let fileSize: UInt64 - let modifiedAt: Date - let id: String - let title: String - let preview: String? - let model: String? - let repository: String? - let branch: String? - let startedAt: Date - let updatedAt: Date - let tokenCount: Int? - let status: String - let summaryComplete: Bool? - - func merging(source: SourceFile) -> Self { - Self( - relativePath: source.relativePath, - fileSize: source.fileSize, - modifiedAt: source.modifiedAt, - id: id, - title: title, - preview: preview, - model: model, - repository: repository, - branch: branch, - startedAt: startedAt, - updatedAt: updatedAt, - tokenCount: tokenCount, - status: source.status == "Archived" ? "Archived" : status, - summaryComplete: summaryComplete - ) - } - } - - private static func eventDate( - object: [String: Any], - payload: [String: Any] - ) -> Date? { - date(object["timestamp"]) ?? date(payload["timestamp"]) - ?? date(payload["started_at"]) ?? date(payload["completed_at"]) - } - - private static func messageText(_ payload: [String: Any]) -> String? { - guard let content = payload["content"] as? [[String: Any]] else { return nil } - return content.compactMap { item -> String? in - let type = item["type"] as? String - guard type == "input_text" || type == "output_text" else { return nil } - return item["text"] as? String - } - .joined(separator: "\n") - } - - private static func payloadText(_ payload: [String: Any]) -> String { - if let text = payload["text"] as? String { return cleanTranscriptText(text) } - if let message = payload["message"] as? String { return cleanTranscriptText(message) } - if let output = payload["output"] as? String { return cleanTranscriptText(output) } - return "" } private static func date(_ raw: Any?) -> Date? { @@ -2879,19 +1010,6 @@ public struct LocalChatScanner: @unchecked Sendable { return Date(timeIntervalSince1970: value > 100_000_000_000 ? value / 1_000 : value) } - private static func date(fromSQLite raw: Any?) -> Date? { - let value: Double? - if let number = raw as? NSNumber { - value = number.doubleValue - } else if let string = raw as? String { - value = Double(string) - } else { - value = nil - } - guard let value, value > 0 else { return nil } - return Date(timeIntervalSince1970: value > 100_000_000_000 ? value / 1_000 : value) - } - private static func intValue(_ raw: Any?) -> Int? { if raw is NSNull { return nil } if let number = raw as? NSNumber { return number.intValue } @@ -2916,31 +1034,6 @@ public struct LocalChatScanner: @unchecked Sendable { return false } - private static func visibleMessageText(_ raw: String, role: LocalChatRole) -> String { - var text = raw - if role == .user { - if let delegatedInput = firstCapture( - in: text, - pattern: #"[\s\S]*?([\s\S]*?)[\s\S]*?"# - ) { - text = delegatedInput - } else { - for pattern in [ - #"[\s\S]*?"#, - #"# AGENTS\.md instructions\s*[\s\S]*?[\s\S]*?"#, - #"[\s\S]*?"# - ] { - text = text.replacingOccurrences( - of: pattern, - with: "", - options: .regularExpression - ) - } - } - } - return cleanTranscriptText(text) - } - private static func cleanTranscriptText(_ raw: String) -> String { var text = raw let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -2965,62 +1058,6 @@ public struct LocalChatScanner: @unchecked Sendable { return text.trimmingCharacters(in: .whitespacesAndNewlines) } - private static func cleanToolText(_ raw: String) -> String { - cleanTranscriptText(raw).replacingOccurrences( - of: #"\b[A-Za-z0-9_=+-]{64,}\b"#, - with: "[redacted opaque value]", - options: .regularExpression - ) - } - - private static func jsonText(_ value: Any?) -> String { - guard - let value, - JSONSerialization.isValidJSONObject(value), - let data = try? JSONSerialization.data( - withJSONObject: value, - options: [.sortedKeys, .withoutEscapingSlashes] - ) - else { - return value as? String ?? "" - } - return String(decoding: data, as: UTF8.self) - } - - private static func claudeContentText(_ value: Any?) -> String { - if let text = value as? String { return text } - if let blocks = value as? [[String: Any]] { - return blocks.compactMap { block -> String? in - if let text = block["text"] as? String { return text } - if let content = block["content"] as? String { return content } - return nil - }.joined(separator: "\n") - } - return jsonText(value) - } - - private static func claudeErrorText(_ value: Any?) -> String { - let text = cleanToolText( - (value as? String) ?? jsonText(value) - ) - return text.isEmpty ? "Claude reported an error for this event." : text - } - - private static func unsupportedClaudeEntry( - id: String, - type: String, - timestamp: Date? - ) -> LocalChatTranscriptEntry { - .init( - id: id, - kind: .activity, - title: "Unsupported event", - text: "Claude history event “\(cleanMetadata(type) ?? "unknown")” is not rendered.", - timestamp: timestamp, - isCollapsible: true - ) - } - private static func cleanMetadata(_ value: String?) -> String? { guard let value else { return nil } let cleaned = cleanTranscriptText(value) @@ -3067,66 +1104,6 @@ public struct LocalChatScanner: @unchecked Sendable { && !value.contains("\0") } - private static func safeLastPathComponent(_ value: String?) -> String? { - guard let value, !value.isEmpty else { return nil } - return cleanMetadata(URL(fileURLWithPath: value).lastPathComponent) - } - - private static func firstCapture(in text: String, pattern: String) -> String? { - guard - let expression = try? NSRegularExpression(pattern: pattern), - let match = expression.firstMatch( - in: text, - range: NSRange(text.startIndex..., in: text) - ), - match.numberOfRanges > 1, - let range = Range(match.range(at: 1), in: text) - else { - return nil - } - return String(text[range]) - } - - private static func title(from text: String?) -> String { - guard let text else { return "Untitled chat" } - let firstLine = text.split(whereSeparator: \.isNewline).first.map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !firstLine.isEmpty else { return "Untitled chat" } - return firstLine.count <= 80 ? firstLine : String(firstLine.prefix(77)) + "…" - } - - private static func preview(from text: String?) -> String? { - guard let text else { return nil } - let compact = text.replacingOccurrences( - of: #"\s+"#, - with: " ", - options: .regularExpression - ) - guard !compact.isEmpty else { return nil } - return compact.count <= 180 ? compact : String(compact.prefix(177)) + "…" - } - - private static func commandLike(_ name: String) -> Bool { - let value = name.lowercased() - return value.contains("shell") || value.contains("exec") || value.contains("command") - } - - private static let privateToolNames: Set = [ - "send_message", "followup_task", "spawn_agent" - ] - - private static func humanized(_ value: String) -> String { - value.replacingOccurrences(of: "_", with: " ").capitalized - } - - private static func byteCount(_ count: UInt64) -> String { - ByteCountFormatter.string(fromByteCount: Int64(clamping: count), countStyle: .file) - } - - private static func sameModificationDate(_ lhs: Date, _ rhs: Date) -> Bool { - abs(lhs.timeIntervalSince(rhs)) < 0.002 - } - private static func fnv1a64(_ value: String) -> UInt64 { value.utf8.reduce(14_695_981_039_346_656_037) { ($0 ^ UInt64($1)) &* 1_099_511_628_211 diff --git a/Tests/AgentDockCoreTests/LocalChatScannerTests.swift b/Tests/AgentDockCoreTests/LocalChatScannerTests.swift deleted file mode 100644 index e45e7fb..0000000 --- a/Tests/AgentDockCoreTests/LocalChatScannerTests.swift +++ /dev/null @@ -1,824 +0,0 @@ -import XCTest -@testable import AgentDockCore - -final class LocalChatScannerTests: XCTestCase { - private var root: URL! - private var indexRoot: URL! - - override func setUpWithError() throws { - root = FileManager.default.temporaryDirectory - .appendingPathComponent("LocalChatScannerTests-\(UUID().uuidString)", isDirectory: true) - indexRoot = root.appendingPathComponent("Indexes", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - } - - override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: root) - root = nil - indexRoot = nil - } - - func testFallbackIndexAndPagedTranscriptPreserveSupportedSourceEvents() throws { - let profile = makeProfile("Work") - let file = try sessionFile(profile: profile, name: "rollout-test.jsonl") - try writeRecords([ - sessionMeta(id: "session-123456789", cwd: "/private/work/AgentDock"), - [ - "timestamp": "2026-07-28T10:00:01Z", - "type": "turn_context", - "payload": ["model": "gpt-5.6"] - ], - message(role: "developer", text: "private instructions", second: 2), - message(role: "user", text: "Review profile isolation", second: 3), - [ - "timestamp": "2026-07-28T10:00:04Z", - "type": "world_state", - "payload": ["full": "must not render"] - ], - [ - "timestamp": "2026-07-28T10:00:05Z", - "type": "response_item", - "payload": [ - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\":\"swift test\",\"workdir\":\"/private/tmp/project\"}" - ] - ], - [ - "timestamp": "2026-07-28T10:00:05Z", - "type": "response_item", - "payload": [ - "type": "function_call", - "name": "send_message", - "arguments": "{\"message\":\"gAAAAABsecretopaquevalue123456789012345678901234567890\"}" - ] - ], - [ - "timestamp": "2026-07-28T10:00:05Z", - "type": "unsupported_future_event", - "payload": ["value": "valid but unsupported"] - ], - message(role: "assistant", text: "Isolation is intact.", second: 6), - [ - "timestamp": "2026-07-28T10:00:07Z", - "type": "event_msg", - "payload": ["type": "task_complete"] - ] - ], to: file) - - let scanner = makeScanner() - let result = scanner.scan(profile: profile) - let summary = try XCTUnwrap(result.sessions.first) - let entries = allEntries(scanner: scanner, session: summary) - var forwardEntries: [LocalChatTranscriptEntry] = [] - var forwardCursor: LocalChatTranscriptCursor? - repeat { - let page = scanner.loadTranscriptForwardPage(for: summary, after: forwardCursor) - forwardEntries += page.entries - forwardCursor = page.olderCursor - } while forwardCursor != nil - - XCTAssertEqual(result.diagnostics.parsedFileCount, 1) - XCTAssertEqual(summary.id, "session-123456789") - XCTAssertEqual(summary.title, "Review profile isolation") - XCTAssertEqual(summary.model, "gpt-5.6") - XCTAssertEqual(summary.repository, "AgentDock") - XCTAssertTrue(summary.messages.isEmpty) - XCTAssertEqual(entries.compactMap(\.role), [.user, .assistant]) - XCTAssertEqual(forwardEntries.compactMap(\.role), [.user, .assistant]) - XCTAssertEqual( - forwardEntries.compactMap(\.message).map(\.text), - ["Review profile isolation", "Isolation is intact."] - ) - XCTAssertTrue(entries.contains { $0.kind == .command && $0.text.contains("swift test") }) - XCTAssertTrue(entries.contains { $0.kind == .command && $0.text.contains("…/project") }) - XCTAssertTrue(entries.contains { - $0.title == "send_message" && $0.text == "Private agent coordination payload omitted." - }) - XCTAssertTrue(entries.contains { $0.kind == .status && $0.title == "Completed" }) - XCTAssertFalse(entries.contains { $0.kind == .malformed }) - XCTAssertFalse(entries.contains { $0.text.contains("gAAAAA") }) - XCTAssertFalse(entries.contains { $0.text.contains("/private/tmp") }) - XCTAssertFalse(entries.contains { $0.text.contains("private instructions") }) - XCTAssertFalse(entries.contains { $0.text.contains("must not render") }) - XCTAssertTrue(entries.contains { - $0.kind == .unsupported && $0.text.contains("Unsupported Future Event") - }) - } - - func testPartialDatabaseIndexStillDiscoversUnindexedSessionFiles() throws { - let profile = makeProfile("Partial Database") - let indexed = try sessionFile(profile: profile, name: "rollout-indexed.jsonl") - let fallback = try sessionFile(profile: profile, name: "rollout-fallback.jsonl") - try writeRecords([ - sessionMeta(id: "indexed-session"), - message(role: "user", text: "Indexed conversation", second: 1) - ], to: indexed) - try writeRecords([ - sessionMeta(id: "fallback-session"), - message(role: "user", text: "Filesystem conversation", second: 2) - ], to: fallback) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - try runSQLite(database, sql: """ - create table threads ( - id text, - rollout_path text, - title text, - updated_at integer - ); - insert into threads values ( - 'indexed-session', - '\(indexed.path)', - 'Database conversation', - 1785232860 - ); - """) - - let result = makeScanner().scan(profile: profile) - - XCTAssertTrue(result.diagnostics.usedDatabase) - XCTAssertEqual(result.diagnostics.parsedFileCount, 1) - XCTAssertEqual(Set(result.sessions.map(\.id)), ["indexed-session", "fallback-session"]) - XCTAssertEqual( - result.sessions.first { $0.id == "indexed-session" }?.title, - "Database conversation" - ) - XCTAssertEqual( - result.sessions.first { $0.id == "fallback-session" }?.title, - "Filesystem conversation" - ) - } - - func testArchivedOnlyHistoryIsIndexed() throws { - let profile = makeProfile("Archive") - let file = try sessionFile( - profile: profile, - rootName: "archived_sessions", - name: "rollout-archived.jsonl" - ) - try writeRecords([ - sessionMeta(id: "archived-session"), - message(role: "user", text: "Read archived chat", second: 1) - ], to: file) - - let result = makeScanner().scan(profile: profile) - - XCTAssertEqual(result.sessions.count, 1) - XCTAssertEqual(result.sessions.first?.id, "archived-session") - XCTAssertEqual(result.sessions.first?.status, "Archived") - } - - func testHundredsOfSessionsUseDurableCacheAndOnlyReparseChangedFile() throws { - let profile = makeProfile("Many") - var files: [URL] = [] - for index in 0..<350 { - let file = try sessionFile( - profile: profile, - name: "rollout-\(String(format: "%04d", index)).jsonl" - ) - try writeRecords([ - sessionMeta(id: "session-\(index)"), - message(role: "user", text: "Conversation \(index)", second: index % 60) - ], to: file) - files.append(file) - } - let scanner = makeScanner(maximumSessions: 500) - - let first = scanner.scan(profile: profile) - let second = scanner.scan(profile: profile) - try FileHandle(forWritingTo: files[173]).use { handle in - try handle.seekToEnd() - try handle.write(contentsOf: Data("\n".utf8)) - } - let third = scanner.scan(profile: profile) - - XCTAssertEqual(first.sessions.count, 350) - XCTAssertEqual(first.diagnostics.parsedFileCount, 350) - XCTAssertEqual(second.diagnostics.cacheHitCount, 350) - XCTAssertEqual(second.diagnostics.parsedFileCount, 0) - XCTAssertEqual(third.diagnostics.parsedFileCount, 1) - XCTAssertEqual(third.diagnostics.cacheHitCount, 349) - - let index = try XCTUnwrap( - FileManager.default.contentsOfDirectory( - at: indexRoot, - includingPropertiesForKeys: nil - ).first - ) - let data = try Data(contentsOf: index) - let text = String(decoding: data, as: UTF8.self) - XCTAssertFalse(text.contains(profile.codexHomePath.path)) - XCTAssertEqual( - try FileManager.default.attributesOfItem(atPath: index.path)[.posixPermissions] as? Int, - 0o600 - ) - scanner.removeIndex(profileID: profile.id) - XCTAssertFalse(FileManager.default.fileExists(atPath: index.path)) - } - - func testCodexSummaryBoundsEachSourceAndKeepsFullTranscriptAvailable() throws { - let profile = makeProfile("Bounded") - let file = try sessionFile(profile: profile, name: "rollout-bounded.jsonl") - try writeRecords([ - sessionMeta(id: "bounded-session"), - ["type": "ignored", "payload": ["padding": String(repeating: "a", count: 2_048)]], - message(role: "user", text: "Message beyond summary budget", second: 1) - ], to: file) - let scanner = LocalChatScanner(maximumSummaryLineBytes: 1_024, indexRootURL: indexRoot) - - let result = scanner.scan(profile: profile) - let session = try XCTUnwrap(result.sessions.first) - - XCTAssertEqual(result.sessions.count, 1) - XCTAssertEqual(session.id, "bounded-session") - XCTAssertEqual(session.title, "Untitled chat") - XCTAssertTrue(allEntries(scanner: scanner, session: session).contains { - $0.message?.text == "Message beyond summary budget" - }) - } - - func testCodexSummaryBoundsAggregateReadBytesWithoutDroppingSessions() throws { - let profile = makeProfile("Aggregate") - for index in 0..<3 { - let file = try sessionFile(profile: profile, name: "rollout-\(index).jsonl") - try writeRecords([ - ["type": "ignored", "payload": ["padding": String(repeating: "a", count: 2_048)]], - message(role: "user", text: "Message beyond budget", second: 1) - ], to: file) - } - let scanner = LocalChatScanner( - maximumMetadataBytes: 1_024, - indexRootURL: indexRoot - ) - let result = scanner.scan(profile: profile) - - XCTAssertEqual(result.sessions.count, 3) - XCTAssertTrue(result.sessions.allSatisfy { $0.title == "Untitled chat" }) - XCTAssertEqual(result.diagnostics.parsedFileCount, 1) - XCTAssertEqual(scanner.scan(profile: profile).diagnostics.parsedFileCount, 1) - XCTAssertEqual(scanner.scan(profile: profile).diagnostics.parsedFileCount, 1) - XCTAssertEqual(scanner.scan(profile: profile).diagnostics.cacheHitCount, 3) - } - - func testManagedCacheDoesNotReuseMetadataAfterSourceRootChanges() throws { - let original = makeProfile("Original") - var replacement = makeProfile("Replacement") - replacement.id = original.id - let originalFile = try sessionFile(profile: original, name: "rollout-shared.jsonl") - let replacementFile = try sessionFile(profile: replacement, name: "rollout-shared.jsonl") - try writeRecords([ - sessionMeta(id: "shared-session"), - message(role: "user", text: "Account A", second: 1) - ], to: originalFile) - try writeRecords([ - sessionMeta(id: "shared-session"), - message(role: "user", text: "Account B", second: 1) - ], to: replacementFile) - let modifiedAt = Date(timeIntervalSince1970: 1_800_000_000) - for file in [originalFile, replacementFile] { - try FileManager.default.setAttributes( - [.modificationDate: modifiedAt], - ofItemAtPath: file.path - ) - } - let scanner = makeScanner() - XCTAssertEqual(scanner.scan(profile: original).sessions.first?.title, "Account A") - - let result = scanner.scan(profile: replacement) - - XCTAssertEqual(result.sessions.first?.title, "Account B") - XCTAssertEqual(result.diagnostics.parsedFileCount, 1) - XCTAssertEqual(result.diagnostics.cacheHitCount, 0) - } - - func testDuplicateCachedPathsAreDiscardedAndRebuilt() throws { - let profile = makeProfile("Duplicate") - let file = try sessionFile(profile: profile, name: "rollout-duplicate.jsonl") - try writeRecords([ - sessionMeta(id: "duplicate-session"), - message(role: "user", text: "Original content", second: 1) - ], to: file) - let scanner = makeScanner() - XCTAssertEqual(scanner.scan(profile: profile).sessions.count, 1) - let index = try XCTUnwrap(FileManager.default.contentsOfDirectory( - at: indexRoot, - includingPropertiesForKeys: nil - ).first) - var document = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(contentsOf: index)) as? [String: Any] - ) - let records = try XCTUnwrap(document["records"] as? [[String: Any]]) - document["records"] = records + records - try JSONSerialization.data(withJSONObject: document).write(to: index) - - let result = scanner.scan(profile: profile) - - XCTAssertEqual(result.sessions.map(\.title), ["Original content"]) - XCTAssertEqual(result.diagnostics.parsedFileCount, 1) - } - - func testUnchangedScanDoesNotRewriteDurableIndex() throws { - let profile = makeProfile("Unchanged") - let file = try sessionFile(profile: profile, name: "rollout-unchanged.jsonl") - try writeRecords([ - sessionMeta(id: "unchanged-session"), - message(role: "user", text: "Stable content", second: 1) - ], to: file) - let scanner = makeScanner() - XCTAssertEqual(scanner.scan(profile: profile).sessions.count, 1) - let index = try XCTUnwrap(FileManager.default.contentsOfDirectory( - at: indexRoot, - includingPropertiesForKeys: nil - ).first) - let modifiedAt = Date(timeIntervalSince1970: 1_800_000_000) - try FileManager.default.setAttributes( - [.modificationDate: modifiedAt], - ofItemAtPath: index.path - ) - - XCTAssertEqual(scanner.scan(profile: profile).diagnostics.cacheHitCount, 1) - XCTAssertEqual( - try FileManager.default.attributesOfItem(atPath: index.path)[.modificationDate] as? Date, - modifiedAt - ) - } - - func testCachedTimestampRoundTripDoesNotReportUnchangedTranscriptAsChanged() throws { - let profile = makeProfile("Cached Transcript") - let file = try sessionFile(profile: profile, name: "rollout-cached.jsonl") - try writeRecords([ - sessionMeta(id: "cached-session"), - message(role: "user", text: "Stable conversation", second: 1) - ], to: file) - try FileManager.default.setAttributes( - [.modificationDate: Date(timeIntervalSince1970: 1_800_000_000.1234567)], - ofItemAtPath: file.path - ) - let scanner = makeScanner() - _ = scanner.scan(profile: profile) - let cached = scanner.scan(profile: profile) - XCTAssertEqual(cached.diagnostics.cacheHitCount, 1) - let session = try XCTUnwrap(cached.sessions.first) - - let page = scanner.loadTranscriptForwardPage(for: session) - XCTAssertFalse(page.sourceChanged) - XCTAssertTrue(page.entries.contains { $0.text == "Stable conversation" }) - } - - func testWarmScanRejectsCachedSourceReplacedWithSymlink() throws { - let profile = makeProfile("Replaced Source") - let file = try sessionFile(profile: profile, name: "rollout-replaced.jsonl") - try writeRecords([ - sessionMeta(id: "replaced-session"), - message(role: "user", text: "Original conversation", second: 1) - ], to: file) - let scanner = makeScanner() - _ = scanner.scan(profile: profile) - let warm = scanner.scan(profile: profile) - XCTAssertEqual(warm.diagnostics.cacheHitCount, 1) - let session = try XCTUnwrap(warm.sessions.first) - - let movedFile = root.appendingPathComponent("outside-session.jsonl") - try FileManager.default.moveItem(at: file, to: movedFile) - try FileManager.default.createSymbolicLink(at: file, withDestinationURL: movedFile) - - let replaced = scanner.scan(profile: profile) - XCTAssertTrue(replaced.sessions.isEmpty) - XCTAssertEqual(replaced.diagnostics.cacheHitCount, 0) - XCTAssertEqual(replaced.diagnostics.sourceFileCount, 0) - let page = scanner.loadTranscriptPage(for: session) - XCTAssertEqual(page.entries.map(\.kind), [.error]) - - try FileManager.default.removeItem(at: file) - try FileManager.default.moveItem(at: movedFile, to: file) - let restored = scanner.scan(profile: profile) - XCTAssertEqual(restored.sessions.map(\.id), ["replaced-session"]) - XCTAssertEqual(restored.diagnostics.parsedFileCount, 1) - } - - func testWarmScanRevalidatesSessionRootAfterSymlinkReplacement() throws { - let profile = makeProfile("Replaced Root") - let file = try sessionFile(profile: profile, name: "rollout-root.jsonl") - try writeRecords([ - sessionMeta(id: "root-session"), - message(role: "user", text: "Original conversation", second: 1) - ], to: file) - let scanner = makeScanner() - _ = scanner.scan(profile: profile) - XCTAssertEqual(scanner.scan(profile: profile).diagnostics.cacheHitCount, 1) - let sessionsRoot = profile.codexHomePath.appendingPathComponent("sessions", isDirectory: true) - let movedRoot = root.appendingPathComponent("outside-sessions", isDirectory: true) - try FileManager.default.moveItem(at: sessionsRoot, to: movedRoot) - try FileManager.default.createSymbolicLink(at: sessionsRoot, withDestinationURL: movedRoot) - - let replaced = scanner.scan(profile: profile) - XCTAssertTrue(replaced.sessions.isEmpty) - XCTAssertEqual(replaced.diagnostics.cacheHitCount, 0) - XCTAssertEqual(replaced.diagnostics.sourceFileCount, 0) - - try FileManager.default.removeItem(at: sessionsRoot) - try FileManager.default.moveItem(at: movedRoot, to: sessionsRoot) - let restored = scanner.scan(profile: profile) - XCTAssertEqual(restored.sessions.map(\.id), ["root-session"]) - XCTAssertEqual(restored.diagnostics.parsedFileCount, 1) - } - - func testTranscriptOverTwentyMegabytesAndOversizedLineRemainVisible() throws { - let profile = makeProfile("Large") - let file = try sessionFile(profile: profile, name: "rollout-large.jsonl") - try writeRecords([ - sessionMeta(id: "large-session"), - message(role: "user", text: "Open the large chat", second: 1) - ], to: file) - let handle = try FileHandle(forWritingTo: file) - try handle.seekToEnd() - try handle.write(contentsOf: Data("\n{\"type\":\"tool_output\",\"payload\":{\"blob\":\"".utf8)) - try handle.write(contentsOf: Data(repeating: 0x61, count: 21 * 1_024 * 1_024)) - try handle.write(contentsOf: Data("\"}}\n".utf8)) - try handle.write(contentsOf: try lineData( - message(role: "assistant", text: "Large chat loaded.", second: 2) - )) - try handle.close() - - let scanner = makeScanner() - let summary = try XCTUnwrap(scanner.scan(profile: profile).sessions.first) - let entries = allEntries(scanner: scanner, session: summary) - var forwardEntries: [LocalChatTranscriptEntry] = [] - var cursor: LocalChatTranscriptCursor? - repeat { - let page = scanner.loadTranscriptForwardPage(for: summary, after: cursor) - forwardEntries += page.entries - cursor = page.olderCursor - } while cursor != nil - - XCTAssertGreaterThan(summary.sourceSize, 20 * 1_024 * 1_024) - XCTAssertEqual( - entries.filter { $0.kind == .message }.map(\.text), - ["Open the large chat", "Large chat loaded."] - ) - XCTAssertTrue(entries.contains { $0.kind == .oversized }) - XCTAssertEqual( - forwardEntries.filter { $0.kind == .message }.map(\.text), - ["Open the large chat", "Large chat loaded."] - ) - XCTAssertTrue(forwardEntries.contains { $0.kind == .oversized }) - } - - func testOversizedOnlyContentStillProducesAListRecord() throws { - let profile = makeProfile("Oversized") - let file = try sessionFile(profile: profile, name: "rollout-oversized.jsonl") - let handle = FileManager.default.createFile(atPath: file.path, contents: nil) - XCTAssertTrue(handle) - let writer = try FileHandle(forWritingTo: file) - try writer.write(contentsOf: Data("{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"".utf8)) - try writer.write(contentsOf: Data(repeating: 0x62, count: 3 * 1_024 * 1_024)) - try writer.write(contentsOf: Data("\"}]}}\n".utf8)) - try writer.close() - - let result = makeScanner().scan(profile: profile) - - XCTAssertEqual(result.sessions.count, 1) - XCTAssertEqual(result.sessions.first?.title, "Untitled chat") - XCTAssertTrue( - allEntries(scanner: makeScanner(), session: try XCTUnwrap(result.sessions.first)) - .contains { $0.kind == .oversized } - ) - } - - func testMalformedAndTruncatedTailAreRepresentedHonestly() throws { - let profile = makeProfile("Partial") - let file = try sessionFile(profile: profile, name: "rollout-partial.jsonl") - try writeRecords([ - sessionMeta(id: "partial-session"), - message(role: "user", text: "Before the partial event", second: 1) - ], to: file) - let handle = try FileHandle(forWritingTo: file) - try handle.seekToEnd() - try handle.write(contentsOf: Data("\n{\"type\":\"response_item\",\"payload\":".utf8)) - try handle.close() - - let scanner = makeScanner() - let summary = try XCTUnwrap(scanner.scan(profile: profile).sessions.first) - let page = scanner.loadTranscriptPage(for: summary) - - XCTAssertTrue(page.entries.contains { $0.kind == .message }) - XCTAssertTrue(page.entries.contains { $0.kind == .malformed }) - XCTAssertEqual(page.malformedEventCount, 1) - } - - func testSchemaDriftUsesAvailableColumnsWithoutParsingTranscript() throws { - let profile = makeProfile("Drift") - let file = try sessionFile(profile: profile, name: "rollout-drift.jsonl") - try writeRecords([sessionMeta(id: "file-id")], to: file) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - try runSQLite(database, sql: """ - create table threads ( - rollout_path text not null, - title text, - updated_at_ms integer, - archived text - ); - insert into threads values ( - '\(file.path)', - 'Schema drift title', - 1785232860000, - '0' - ); - """) - - let result = makeScanner().scan(profile: profile) - let session = try XCTUnwrap(result.sessions.first) - - XCTAssertTrue(result.diagnostics.usedDatabase) - XCTAssertEqual(result.diagnostics.parsedFileCount, 0) - XCTAssertEqual(session.title, "Schema drift title") - XCTAssertTrue(session.id.hasPrefix("local-")) - } - - func testIncompatibleDatabaseFallsBackToFiles() throws { - let profile = makeProfile("Fallback") - let file = try sessionFile(profile: profile, name: "rollout-fallback.jsonl") - try writeRecords([ - sessionMeta(id: "fallback-session"), - message(role: "user", text: "Filesystem fallback", second: 1) - ], to: file) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - try runSQLite(database, sql: "create table threads (unexpected text);") - - let result = makeScanner().scan(profile: profile) - - XCTAssertFalse(result.diagnostics.usedDatabase) - XCTAssertEqual(result.sessions.first?.title, "Filesystem fallback") - } - - func testClosedWALDatabaseWithoutSidecarsRemainsReadable() throws { - let profile = makeProfile("WAL") - let file = try sessionFile(profile: profile, name: "rollout-wal.jsonl") - try writeRecords([sessionMeta(id: "wal-session")], to: file) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - try runSQLite(database, sql: """ - pragma journal_mode=wal; - create table threads ( - id text, - rollout_path text, - title text, - updated_at integer - ); - insert into threads values ( - 'wal-session', - '\(file.path)', - 'Checkpointed WAL chat', - 1785232860 - ); - pragma wal_checkpoint(truncate); - """) - try? FileManager.default.removeItem(at: URL(fileURLWithPath: database.path + "-wal")) - try? FileManager.default.removeItem(at: URL(fileURLWithPath: database.path + "-shm")) - - let result = makeScanner().scan(profile: profile) - - XCTAssertTrue(result.diagnostics.usedDatabase) - XCTAssertEqual(result.sessions.first?.title, "Checkpointed WAL chat") - } - - func testActiveWALDatabaseWithSidecarsRemainsReadable() throws { - let profile = makeProfile("Active WAL") - let file = try sessionFile(profile: profile, name: "rollout-active-wal.jsonl") - try writeRecords([sessionMeta(id: "active-wal-session")], to: file) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - let process = Process() - let input = Pipe() - let output = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") - process.arguments = [database.path] - process.standardInput = input - process.standardOutput = output - process.standardError = Pipe() - try process.run() - defer { - try? input.fileHandleForWriting.write(contentsOf: Data(".quit\n".utf8)) - process.waitUntilExit() - } - let sql = """ - pragma journal_mode=wal; - create table threads ( - id text, - rollout_path text, - title text, - updated_at integer - ); - insert into threads values ( - 'active-wal-session', - '\(file.path)', - 'Active WAL chat', - 1785232860 - ); - select 'agentdock-ready'; - - """ - try input.fileHandleForWriting.write(contentsOf: Data(sql.utf8)) - var sqliteOutput = Data() - while !sqliteOutput.contains(Data("agentdock-ready\n".utf8)) { - let chunk = output.fileHandleForReading.availableData - guard !chunk.isEmpty else { break } - sqliteOutput.append(chunk) - } - XCTAssertTrue(sqliteOutput.contains(Data("agentdock-ready\n".utf8))) - let wal = URL(fileURLWithPath: database.path + "-wal") - XCTAssertTrue(FileManager.default.fileExists(atPath: wal.path)) - - let result = makeScanner().scan(profile: profile) - - XCTAssertTrue(result.diagnostics.usedDatabase) - XCTAssertEqual(result.sessions.first?.title, "Active WAL chat") - } - - func testSQLiteSharedMemoryChurnDoesNotTriggerChatRefresh() throws { - let profile = makeProfile("Stable") - try FileManager.default.createDirectory( - at: profile.codexHomePath, - withIntermediateDirectories: true - ) - let database = profile.codexHomePath.appendingPathComponent("state_5.sqlite") - try runSQLite(database, sql: "create table threads (rollout_path text);") - let scanner = makeScanner() - let before = scanner.changeToken(profile: profile) - let sharedMemory = URL(fileURLWithPath: database.path + "-shm") - try Data(repeating: 0x41, count: 32 * 1_024).write(to: sharedMemory) - try FileManager.default.setAttributes( - [.modificationDate: Date().addingTimeInterval(10)], - ofItemAtPath: sharedMemory.path - ) - - XCTAssertEqual(scanner.changeToken(profile: profile), before) - } - - func testCancelledPageLoadDoesNotPublishContent() async throws { - let profile = makeProfile("Cancelled") - let file = try sessionFile(profile: profile, name: "rollout-cancelled.jsonl") - try writeRecords([ - sessionMeta(id: "cancelled-session"), - message(role: "user", text: "Cancel me", second: 1) - ], to: file) - let scanner = makeScanner() - let session = try XCTUnwrap(scanner.scan(profile: profile).sessions.first) - - let task = Task.detached { - withUnsafeCurrentTask { $0?.cancel() } - return scanner.loadTranscriptPage(for: session) - } - let page = await task.value - - XCTAssertTrue(page.entries.isEmpty) - } - - func testManagedClaudeWithoutLocalAgentHistoryIsAvailableAndEmpty() { - let profile = CodexProfile( - product: .claude, - name: "Claude Work", - slug: "work", - rootDirectory: root, - shortcutDirectory: root.appendingPathComponent("Shortcuts") - ) - - let result = makeScanner().scan(profile: profile) - - XCTAssertTrue(result.sessions.isEmpty) - XCTAssertEqual(result.availability, .available) - XCTAssertEqual(result.changeToken, "") - } - - func testCodexUsageRecordsDoNotRenderAsUnsupportedTranscriptItems() throws { - let profile = makeProfile("Usage") - let file = try sessionFile(profile: profile, name: "rollout-usage.jsonl") - try writeRecords([ - sessionMeta(id: "usage-session"), - message(role: "user", text: "Review the package", second: 1), - [ - "type": "token_usage_record", - "payload": [ - "thread_id": "usage-session", - "usage": ["input_tokens": 10, "output_tokens": 5, "total_tokens": 15], - "thread_token_usage": ["total_tokens": 15] - ] - ], - message(role: "assistant", text: "The package is valid.", second: 2), - ["type": "future_history_record", "payload": ["value": "unknown"]] - ], to: file) - - let scanner = makeScanner() - let session = try XCTUnwrap(scanner.scan(profile: profile).sessions.first) - let entries = allEntries(scanner: scanner, session: session) - let forward = scanner.loadTranscriptForwardPage(for: session) - - XCTAssertEqual(entries.map(\.kind), [.message, .message, .unsupported]) - XCTAssertEqual(entries.map(\.id), forward.entries.map(\.id)) - XCTAssertEqual(entries.compactMap(\.message).map(\.text), [ - "Review the package", "The package is valid." - ]) - XCTAssertTrue(entries.last?.text.localizedCaseInsensitiveContains("future history record") == true) - } - - private func makeProfile(_ name: String) -> CodexProfile { - CodexProfile( - name: name, - slug: name.lowercased(), - rootDirectory: root, - shortcutDirectory: root.appendingPathComponent("Shortcuts") - ) - } - - private func makeScanner(maximumSessions: Int = 1_000) -> LocalChatScanner { - LocalChatScanner( - maximumSessions: maximumSessions, - indexRootURL: indexRoot - ) - } - - private func sessionFile( - profile: CodexProfile, - rootName: String = "sessions", - name: String - ) throws -> URL { - let directory = profile.codexHomePath.appendingPathComponent( - rootName == "sessions" ? "\(rootName)/2026/07/28" : rootName, - isDirectory: true - ) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory.appendingPathComponent(name) - } - - private func allEntries( - scanner: LocalChatScanner, - session: LocalChatSession - ) -> [LocalChatTranscriptEntry] { - var page = scanner.loadTranscriptPage(for: session) - var entries = page.entries - var iterations = 0 - while let cursor = page.olderCursor, iterations < 100 { - page = scanner.loadTranscriptPage(for: session, before: cursor) - entries = page.entries + entries - iterations += 1 - } - return entries - } - - private func sessionMeta(id: String, cwd: String? = nil) -> [String: Any] { - var payload: [String: Any] = [ - "id": id, - "timestamp": "2026-07-28T10:00:00Z" - ] - if let cwd { - payload["cwd"] = cwd - payload["git"] = ["branch": "codex/history-index"] - } - return [ - "timestamp": "2026-07-28T10:00:00Z", - "type": "session_meta", - "payload": payload - ] - } - - private func message(role: String, text: String, second: Int) -> [String: Any] { - [ - "timestamp": String(format: "2026-07-28T10:00:%02dZ", second), - "type": "response_item", - "payload": [ - "type": "message", - "role": role, - "content": [[ - "type": role == "assistant" ? "output_text" : "input_text", - "text": text - ]] - ] - ] - } - - private func writeRecords(_ records: [[String: Any]], to file: URL) throws { - try records.map { String(decoding: try lineData($0), as: UTF8.self) } - .joined(separator: "\n") - .data(using: .utf8)! - .write(to: file) - } - - private func lineData(_ record: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: record) - } - - private func runSQLite(_ database: URL, sql: String) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") - process.arguments = [database.path, sql] - try process.run() - process.waitUntilExit() - XCTAssertEqual(process.terminationStatus, 0) - } - -} - -private extension FileHandle { - func use(_ body: (FileHandle) throws -> Void) throws { - defer { try? close() } - try body(self) - } -} diff --git a/Tests/AgentDockCoreTests/LocalChatSessionTests.swift b/Tests/AgentDockCoreTests/LocalChatSessionTests.swift index 2e13253..46d1082 100644 --- a/Tests/AgentDockCoreTests/LocalChatSessionTests.swift +++ b/Tests/AgentDockCoreTests/LocalChatSessionTests.swift @@ -21,17 +21,13 @@ final class LocalChatSessionTests: XCTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) ) - let result = scanner.scanOfficialClaude( + let result = scanner.scanOfficialClaudeForStats( claudeHomeURL: userData, claudeCodeHomeURL: claudeCodeHome ) - XCTAssertEqual(result.availability, .available) XCTAssertFalse(result.sessions.isEmpty) - XCTAssertTrue(result.sessions.allSatisfy { session in - session.provider == .claude - && fileManager.isReadableFile(atPath: session.sourceURL.path) - }) + XCTAssertTrue(result.sessions.allSatisfy { $0.provider == .claude }) } private var root: URL! @@ -47,55 +43,20 @@ final class LocalChatSessionTests: XCTestCase { root = nil } - func testCoworkHistoryUsesMetadataAndPreservesAuditOrder() throws { - let fixture = try makeCoworkFixture() - let scanner = LocalChatScanner( - transcriptPageBytes: 64 * 1_024, - transcriptPageEntries: 3, - indexRootURL: root.appendingPathComponent("Indexes") - ) + func testCoworkHistoryUsesMetadataForStatsSession() throws { + try makeCoworkFixture() + let scanner = LocalChatScanner(indexRootURL: root.appendingPathComponent("Indexes")) - let result = scanner.scanOfficialClaude(claudeHomeURL: root) + let result = scanStats(scanner, root) let session = try XCTUnwrap(result.sessions.first) - let entries = forwardEntries(scanner: scanner, session: session) - XCTAssertEqual(result.availability, .available) XCTAssertEqual(result.sessions.count, 1) XCTAssertEqual(session.id, "cowork-session") XCTAssertEqual(session.provider, .claude) XCTAssertEqual(session.profileName, "Official Claude") - XCTAssertEqual(session.title, "Review the release") - XCTAssertEqual(session.preview, "Check the local release evidence") XCTAssertEqual(session.model, "claude-opus-4-1") - XCTAssertEqual(session.repository, "AgentDock") - XCTAssertNil(session.branch) XCTAssertEqual(session.status, "Unknown") - XCTAssertEqual(session.sourceURL, fixture.audit) - XCTAssertEqual( - entries.map(\.kind), - [.status, .message, .message, .reasoning, .command, .activity, .status, .activity] - ) - XCTAssertEqual( - entries.compactMap(\.message).map(\.text), - ["Inspect the package", "The package is valid."] - ) - XCTAssertEqual(entries[3].text, "I should verify the artifact.") - XCTAssertEqual(entries[4].title, "exec_command") - XCTAssertTrue(entries[4].text.contains("swift test")) - XCTAssertEqual(entries[5].title, "Tool result") - XCTAssertEqual(entries[6].title, "Completed") - XCTAssertEqual(entries[7].title, "Unsupported event") - XCTAssertEqual(entries.map(\.sourceOrdinal), Array(0.. [String: Any] { - ["display": title, "project": "/synthetic/project", "sessionId": "session", "timestamp": 1_785_232_860_000] + func record(_ timestamp: Double) -> [String: Any] { + ["display": "Synthetic", "project": "/synthetic/project", "sessionId": "session", "timestamp": timestamp] + } + func updatedAt(_ result: LocalChatScanResult) -> TimeInterval? { + result.sessions.first?.updatedAt.timeIntervalSince1970 } - try writeJSONLines([record("First title")], to: history) + try writeJSONLines([record(1_785_232_860_000)], to: history) let scanner = LocalChatScanner(indexRootURL: root.appendingPathComponent("Indexes")) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: root).sessions.first?.title, "First title") - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: root).diagnostics.cacheHitCount, 1) + XCTAssertEqual(updatedAt(scanStats(scanner, root)), 1_785_232_860) + XCTAssertEqual(scanStats(scanner, root).diagnostics.cacheHitCount, 1) let handle = try FileHandle(forWritingTo: history) try handle.seekToEnd() - try handle.write(contentsOf: Data([0x0A]) + JSONSerialization.data(withJSONObject: record("Later title")) + Data([0x0A])) + try handle.write(contentsOf: Data([0x0A]) + JSONSerialization.data(withJSONObject: record(1_785_232_920_000)) + Data([0x0A])) try handle.close() - let appended = scanner.scanOfficialClaude(claudeHomeURL: root) - XCTAssertEqual(appended.sessions.first?.title, "Later title") + let appended = scanStats(scanner, root) + XCTAssertEqual(updatedAt(appended), 1_785_232_920) XCTAssertGreaterThan(appended.diagnostics.inspectedHistoryBytes, 0) let originalDate = try XCTUnwrap(history.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) - try writeJSONLines([record("Other title")], to: history) + try writeJSONLines([record(1_785_232_990_000)], to: history) try FileManager.default.setAttributes([.modificationDate: originalDate], ofItemAtPath: history.path) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: root).sessions.first?.title, "Other title") + XCTAssertEqual(updatedAt(scanStats(scanner, root)), 1_785_232_990) let outside = root.appendingPathComponent("outside.jsonl") - try writeJSONLines([record("Linked title")], to: outside) + try writeJSONLines([record(1_785_233_000_000)], to: outside) try FileManager.default.removeItem(at: history) try FileManager.default.createSymbolicLink(at: history, withDestinationURL: outside) - let linked = scanner.scanOfficialClaude(claudeHomeURL: root) + let linked = scanStats(scanner, root) XCTAssertTrue(linked.sessions.isEmpty) XCTAssertEqual(linked.diagnostics.cacheHitCount, 0) } @@ -573,14 +425,14 @@ final class LocalChatSessionTests: XCTestCase { maximumClaudeUsageCacheEntries: 3, indexRootURL: root.appendingPathComponent("Indexes") ) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: first).diagnostics.parsedFileCount, 2) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: second).diagnostics.parsedFileCount, 1) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: first).diagnostics.cacheHitCount, 2) + XCTAssertEqual(scanStats(scanner, first).diagnostics.parsedFileCount, 2) + XCTAssertEqual(scanStats(scanner, second).diagnostics.parsedFileCount, 1) + XCTAssertEqual(scanStats(scanner, first).diagnostics.cacheHitCount, 2) try FileManager.default.removeItem(at: oldest.metadata) try makeCoworkFixture(userDataURL: second, fixtureName: "local_newest", sessionID: "newest") - let added = scanner.scanOfficialClaude(claudeHomeURL: second) + let added = scanStats(scanner, second) XCTAssertEqual(added.diagnostics.parsedFileCount, 1) - let retained = scanner.scanOfficialClaude(claudeHomeURL: first) + let retained = scanStats(scanner, first) XCTAssertEqual(retained.diagnostics.cacheHitCount, 2) XCTAssertEqual(retained.diagnostics.parsedFileCount, 0) XCTAssertTrue(retained.sessions.allSatisfy { $0.tokenCount != nil }) @@ -592,32 +444,24 @@ final class LocalChatSessionTests: XCTestCase { } let fixture = try makeCoworkFixture(auditRecords: [usage(100)]) let scanner = LocalChatScanner(indexRootURL: root.appendingPathComponent("Indexes")) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: root).sessions.first?.tokenCount, 100) + XCTAssertEqual(scanStats(scanner, root).sessions.first?.tokenCount, 100) let originalDate = try XCTUnwrap(fixture.audit.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) try writeJSONLines([usage(999)], to: fixture.audit) try FileManager.default.setAttributes([.modificationDate: originalDate], ofItemAtPath: fixture.audit.path) - let rewritten = scanner.scanOfficialClaude(claudeHomeURL: root) + let rewritten = scanStats(scanner, root) XCTAssertEqual(rewritten.sessions.first?.tokenCount, 999) XCTAssertEqual(rewritten.diagnostics.parsedFileCount, 1) let replacement = try JSONSerialization.data(withJSONObject: usage(888), options: [.sortedKeys]) try replacement.write(to: fixture.audit, options: [.atomic]) try FileManager.default.setAttributes([.modificationDate: originalDate], ofItemAtPath: fixture.audit.path) - let replaced = scanner.scanOfficialClaude(claudeHomeURL: root) + let replaced = scanStats(scanner, root) XCTAssertEqual(replaced.sessions.first?.tokenCount, 888) XCTAssertEqual(replaced.diagnostics.parsedFileCount, 1) - XCTAssertEqual(scanner.scanOfficialClaude(claudeHomeURL: root).diagnostics.cacheHitCount, 1) + XCTAssertEqual(scanStats(scanner, root).diagnostics.cacheHitCount, 1) } - func testStatsScanPreservesUsageAndModelsWithoutPresentationDetailsOrChangeToken() throws { - try makeCoworkFixture() - let scanner = LocalChatScanner(indexRootURL: root.appendingPathComponent("Indexes")) - let presentation = scanner.scanOfficialClaude(claudeHomeURL: root) - let summaries = scanner.scanOfficialClaudeForStats(claudeHomeURL: root, claudeCodeHomeURL: root) - XCTAssertEqual(summaries.sessions.map(\.tokenCount), presentation.sessions.map(\.tokenCount)) - XCTAssertEqual(summaries.sessions.map(\.model), presentation.sessions.map(\.model)) - XCTAssertEqual(summaries.sessions.map(\.updatedAt), presentation.sessions.map(\.updatedAt)) - XCTAssertTrue(summaries.changeToken.isEmpty) - XCTAssertTrue(summaries.sessions.allSatisfy { $0.preview == nil && $0.repository == nil && $0.branch == nil }) + private func scanStats(_ scanner: LocalChatScanner, _ home: URL) -> LocalChatScanResult { + scanner.scanOfficialClaudeForStats(claudeHomeURL: home, claudeCodeHomeURL: home) } @discardableResult @@ -625,10 +469,8 @@ final class LocalChatSessionTests: XCTestCase { userDataURL: URL? = nil, fixtureName: String = "local_fixture", sessionID: String = "cowork-session", - title: String = "Review the release", lastActivityAt: Double = 1_785_232_860_000, - auditRecords: [[String: Any]]? = nil, - malformedTail: Bool = false + auditRecords: [[String: Any]]? = nil ) throws -> (metadata: URL, audit: URL) { let userDataURL = userDataURL ?? root! let metadata = userDataURL.appendingPathComponent( @@ -647,7 +489,7 @@ final class LocalChatSessionTests: XCTestCase { ) let metadataObject: [String: Any] = [ "sessionId": sessionID, - "title": title, + "title": "Review the release", "promptSuggestion": "Check the local release evidence", "cwd": "/private/work/AgentDock", "model": "claude-opus-4-1", @@ -680,67 +522,14 @@ final class LocalChatSessionTests: XCTestCase { "_audit_timestamp": "2026-07-28T10:00:02Z", "message": [ "role": "assistant", - "content": [ - ["type": "text", "text": "The package is valid."], - ["type": "thinking", "thinking": "I should verify the artifact."], - [ - "type": "tool_use", - "name": "exec_command", - "input": ["cmd": "swift test"] - ] - ] + "content": [["type": "text", "text": "The package is valid."]] ] - ], - [ - "type": "user", - "uuid": "tool-result-event", - "_audit_timestamp": "2026-07-28T10:00:03Z", - "message": [ - "role": "user", - "content": [[ - "type": "tool_result", - "tool_use_id": "tool-1", - "content": "All tests passed." - ]] - ] - ], - [ - "type": "result", - "uuid": "result-event", - "_audit_timestamp": "2026-07-28T10:00:04Z", - "is_error": false, - "result": "Done" - ], - [ - "type": "attachment", - "uuid": "attachment-event", - "_audit_timestamp": "2026-07-28T10:00:05Z" ] ] try writeJSONLines(records, to: audit) - if malformedTail { - let handle = try FileHandle(forWritingTo: audit) - try handle.seekToEnd() - try handle.write(contentsOf: Data("\n{\"type\":".utf8)) - try handle.close() - } return (metadata, audit) } - private func forwardEntries( - scanner: LocalChatScanner, - session: LocalChatSession - ) -> [LocalChatTranscriptEntry] { - var entries: [LocalChatTranscriptEntry] = [] - var cursor: LocalChatTranscriptCursor? - repeat { - let page = scanner.loadTranscriptForwardPage(for: session, after: cursor) - entries.append(contentsOf: page.entries) - cursor = page.olderCursor - } while cursor != nil - return entries - } - private func writeJSONLines(_ records: [[String: Any]], to url: URL) throws { let lines = try records.map { String(decoding: try JSONSerialization.data( diff --git a/Tests/AgentDockCoreTests/PerformanceRegressionTests.swift b/Tests/AgentDockCoreTests/PerformanceRegressionTests.swift index ae6d8c7..3707aae 100644 --- a/Tests/AgentDockCoreTests/PerformanceRegressionTests.swift +++ b/Tests/AgentDockCoreTests/PerformanceRegressionTests.swift @@ -87,73 +87,6 @@ final class PerformanceRegressionTests: XCTestCase { XCTAssertEqual(store.profiles.count, 250) XCTAssertLessThan(elapsed, .seconds(2)) } - - func testChatIndexAndFirstTranscriptPageStayWithinBudgets() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("ChatIndexPerformance-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: root) } - let profile = CodexProfile( - name: "Performance", - slug: "performance", - rootDirectory: root, - shortcutDirectory: root.appendingPathComponent("Shortcuts") - ) - let sessions = profile.codexHomePath.appendingPathComponent( - "sessions/2026/07/28", - isDirectory: true - ) - try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) - for index in 0..<500 { - let record = """ - {"timestamp":"2026-07-28T10:00:00Z","type":"session_meta","payload":{"id":"performance-\(index)","timestamp":"2026-07-28T10:00:00Z"}} - {"timestamp":"2026-07-28T10:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Performance conversation \(index)"}]}} - {"timestamp":"2026-07-28T10:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"A bounded response with `inline code` and a list.\\n\\n- one\\n- two"}]}} - """ - try Data(record.utf8).write( - to: sessions.appendingPathComponent("rollout-\(index).jsonl") - ) - } - let scanner = LocalChatScanner( - maximumSessions: 500, - indexRootURL: root.appendingPathComponent("Indexes") - ) - let clock = ContinuousClock() - - let coldStart = clock.now - let cold = scanner.scan(profile: profile) - let coldElapsed = coldStart.duration(to: clock.now) - let warmStart = clock.now - let warm = scanner.scan(profile: profile) - let warmElapsed = warmStart.duration(to: clock.now) - let pageStart = clock.now - let page = scanner.loadTranscriptPage(for: try XCTUnwrap(warm.sessions.first)) - let pageElapsed = pageStart.duration(to: clock.now) - - XCTAssertEqual(cold.sessions.count, 500) - XCTAssertEqual(warm.diagnostics.cacheHitCount, 500) - XCTAssertEqual(warm.diagnostics.parsedFileCount, 0) - XCTAssertFalse(page.entries.isEmpty) - XCTAssertLessThan(coldElapsed, .seconds(2)) - XCTAssertLessThan(warmElapsed, .milliseconds(500)) - XCTAssertLessThan(pageElapsed, .milliseconds(250)) - print( - "CHAT_INDEX_BENCHMARK sessions=500 cold_ms=\(milliseconds(coldElapsed)) " - + "warm_ms=\(milliseconds(warmElapsed)) first_page_ms=\(milliseconds(pageElapsed))" - ) - - measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) { - let result = scanner.scan(profile: profile) - XCTAssertEqual(result.sessions.count, 500) - _ = scanner.loadTranscriptPage(for: result.sessions[0]) - } - } - - private func milliseconds(_ duration: Duration) -> String { - let components = duration.components - let value = Double(components.seconds) * 1_000 - + Double(components.attoseconds) / 1_000_000_000_000_000 - return String(format: "%.2f", value) - } } private struct PerformanceNeverInUseChecker: ProfileUsageChecking { diff --git a/docs/apis.md b/docs/apis.md index f81ab76..8c12422 100644 --- a/docs/apis.md +++ b/docs/apis.md @@ -80,10 +80,10 @@ is not evidence of a supported isolation contract. `AgentDock.defaultView = "chats"` migrates to `"overview"` without changing other preferences. - Legacy chat indexes: versioned, bounded summary metadata with relative - source paths and a source-root fingerprint. Core readers reject a different - source root or duplicate cached paths. The app no longer creates these - indexes; explicit profile removal or deletion cleans up that profile's - legacy indexes. Claude activity summaries remain in memory. + source paths and a source-root fingerprint, left by older versions. AgentDock + no longer reads or writes these indexes; explicit profile removal or deletion + deletes that profile's legacy index files. Claude activity summaries remain + in memory. - Analytics state: `undecided`, `denied`, or `granted` in UserDefaults. The analytics boundary never promotes `undecided` without a user action. A random installation UUID exists only while granted and is deleted on opt-out; diff --git a/docs/architecture.md b/docs/architecture.md index bad4f83..ab98030 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ in `AgentDockCore`; the UI consumes only `ProfileRateLimits`. - shortcut creation and validation; - bounded subprocess execution; - provider-aware usage retrieval; -- local activity scanning and shared bounded session/transcript readers; +- local activity scanning, including bounded Claude session readers; - profile-specific MCP callback configuration. Core operations fail closed when a path, process, bundle, signature, ownership diff --git a/docs/code-map.md b/docs/code-map.md index 4372332..aae686f 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -20,7 +20,7 @@ | `Sources/AgentDockCore/CodexLauncher.swift` | Codex app validation and managed process lifecycle | | `Sources/AgentDockCore/ClaudeLauncher.swift` | Claude app validation and managed process lifecycle | | `Sources/AgentDockCore/DesktopAppRegistry.swift` | Official app identities and provider launch contracts | -| `Sources/AgentDockCore/LocalChatSession.swift` | Shared bounded session readers and indexes, including Claude activity-summary input | +| `Sources/AgentDockCore/LocalChatSession.swift` | Bounded Claude session readers for activity summaries and legacy chat-index cleanup | | `Sources/AgentDockCore/BoundedSubprocess.swift` | Timeout and output-bounded subprocess execution | | `Sources/AgentDockCore/ShortcutInstaller.swift` | Profile shortcut generation and replacement | diff --git a/docs/development.md b/docs/development.md index f6bab18..0aa8438 100644 --- a/docs/development.md +++ b/docs/development.md @@ -165,10 +165,10 @@ signed bundle in the installed-app and live lifecycle/isolation checks. These overrides leave the normally installed app in place. Repeat it after provider updates. Both installed-profile checks and runtime launches resolve the current nested CLI bundle with a legacy fallback. -The separate renderer showcase omits Codex accounting-only `token_usage_record` -events from presentation; unknown history events appear as unsupported records. -The main app has no chat browser. Its Claude activity summaries retain shared -bounded session scanning. +The main app has no chat browser, and the core package has no chat-listing or +transcript-paging API. Claude activity summaries use the bounded summary-only +scanner in +[LocalChatSession.swift](../Sources/AgentDockCore/LocalChatSession.swift). These checks can open installed provider applications or terminate the exact active managed profile named in the environment. Run them only on a Mac where @@ -179,7 +179,8 @@ that interaction is expected. - Keep provider-specific discovery and parsing in `AgentDockCore`. - Keep transcript presentation provider-neutral and capability-gated. - Preserve stable event identities and exact source order. -- Bound file inventories, subprocess output, transcript pages, and layout work. +- Bound file inventories, subprocess output, rendered transcript output, and + layout work. - Validate paths and signatures immediately before security-sensitive actions. - Update README or focused docs with user-visible behavior and contract changes. - Never commit profiles, chats, logs, databases, credentials, screenshots with diff --git a/docs/resource-usage-readers.md b/docs/resource-usage-readers.md index 9ca67f6..22958b0 100644 --- a/docs/resource-usage-readers.md +++ b/docs/resource-usage-readers.md @@ -2,9 +2,9 @@ `ProfileStatsScanner` uses summary-only Claude scans. These retain source-backed session timestamps, model attribution, token totals, and local usage-limit -signals while skipping presentation titles, previews, repository and branch -details, and change-token construction. Public session and transcript APIs -retain their supported behavior. +signals. They do not read presentation titles, previews, repository or branch +details, or build change tokens. The core package has no presentation scan or +transcript API. The official Claude Code history index reads the newest 16 MiB by default. It drops an incomplete record at the beginning of that window, visits newest @@ -14,7 +14,7 @@ cover only inspected records when those limits apply. Per-session model headers use a separate 16 MiB scan budget, with at most 256 KiB per session. Header budgets count bytes actually read, including buffered chunks. -History summaries remain in memory for at most four history/mode combinations. +History summaries remain in memory for at most four history files. An unchanged file reuses its summary without reading the history body. Device, inode, size, and nanosecond modification and change times invalidate cached summaries after append, replacement, or rewrite. Reads use the existing regular