From 840e266b5d036b6c4e6f77fc4ab3e0056c4de76f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 16:33:14 +0000 Subject: [PATCH] feat(macos): load sessions via gateway --- apps/macos/Sources/Clawdis/DebugActions.swift | 52 +--- .../Clawdis/MenuContextCardInjector.swift | 9 +- apps/macos/Sources/Clawdis/SessionData.swift | 273 ++++++------------ .../Sources/Clawdis/SessionsSettings.swift | 13 +- .../macos/Sources/Clawdis/WebChatWindow.swift | 18 +- 5 files changed, 96 insertions(+), 269 deletions(-) diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 6ae385d8a..508f1f5b8 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -151,6 +151,7 @@ enum DebugActions { NSApp.terminate(nil) } + @MainActor private static func resolveSessionStorePath() -> String { let defaultPath = SessionLoader.defaultStorePath let configURL = FileManager.default.homeDirectoryForCurrentUser @@ -172,13 +173,8 @@ enum DebugActions { // MARK: - Sessions (thinking / verbose) static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] { - let hints = SessionLoader.configHints() - let store = SessionLoader.resolveStorePath(override: hints.storePath) - let defaults = SessionDefaults( - model: hints.model ?? SessionLoader.fallbackModel, - contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) - guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { return [] } - return Array(rows.prefix(limit)) + guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] } + return Array(snapshot.rows.prefix(limit)) } static func updateSession( @@ -186,44 +182,10 @@ enum DebugActions { thinking: String?, verbose: String?) async throws { - let hints = SessionLoader.configHints() - let store = SessionLoader.resolveStorePath(override: hints.storePath) - let url = URL(fileURLWithPath: store) - guard FileManager.default.fileExists(atPath: store) else { - throw DebugActionError.message("Session store missing at \(store)") - } - - let data = try Data(contentsOf: url) - var decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - var entry = decoded[key] ?? SessionEntryRecord( - sessionId: nil, - updatedAt: Date().timeIntervalSince1970 * 1000, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil) - - entry = SessionEntryRecord( - sessionId: entry.sessionId, - updatedAt: Date().timeIntervalSince1970 * 1000, - systemSent: entry.systemSent, - abortedLastRun: entry.abortedLastRun, - thinkingLevel: thinking, - verboseLevel: verbose, - inputTokens: entry.inputTokens, - outputTokens: entry.outputTokens, - totalTokens: entry.totalTokens, - model: entry.model, - contextTokens: entry.contextTokens) - - decoded[key] = entry - let encoded = try JSONEncoder().encode(decoded) - try encoded.write(to: url, options: [.atomic]) + var params: [String: AnyHashable] = ["key": AnyHashable(key)] + params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) + params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) + _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) } // MARK: - Port diagnostics diff --git a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift index a66ad39b8..6294f3250 100644 --- a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift @@ -135,13 +135,8 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { } private func loadCurrentRows() async throws -> [SessionRow] { - let hints = SessionLoader.configHints() - let store = SessionLoader.resolveStorePath(override: hints.storePath) - let defaults = SessionDefaults( - model: hints.model ?? SessionLoader.fallbackModel, - contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) - - let loaded = try await SessionLoader.loadRows(at: store, defaults: defaults) + let snapshot = try await SessionLoader.loadSnapshot() + let loaded = snapshot.rows let now = Date() let current = loaded.filter { row in if row.key == "main" { return true } diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index 4e89cf21a..231758b75 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -1,9 +1,15 @@ import Foundation import SwiftUI -struct SessionEntryRecord: Codable { - let sessionId: String? +struct GatewaySessionDefaultsRecord: Codable { + let model: String? + let contextTokens: Int? +} + +struct GatewaySessionEntryRecord: Codable { + let key: String let updatedAt: Double? + let sessionId: String? let systemSent: Bool? let abortedLastRun: Bool? let thinkingLevel: String? @@ -15,6 +21,14 @@ struct SessionEntryRecord: Codable { let contextTokens: Int? } +struct GatewaySessionsListResponse: Codable { + let ts: Double? + let path: String + let count: Int + let defaults: GatewaySessionDefaultsRecord? + let sessions: [GatewaySessionEntryRecord] +} + struct SessionTokenStats { let input: Int let output: Int @@ -177,27 +191,28 @@ extension [String] { } } -struct SessionConfigHints { - let storePath: String? - let model: String? - let contextTokens: Int? -} - enum SessionLoadError: LocalizedError { - case missingStore(String) + case gatewayUnavailable(String) case decodeFailed(String) var errorDescription: String? { switch self { - case let .missingStore(path): - "No session store found at \(path) yet. Send or receive a message to create it." + case let .gatewayUnavailable(reason): + "Could not reach the gateway for sessions: \(reason)" case let .decodeFailed(reason): - "Could not read the session store: \(reason)" + "Could not decode gateway session payload: \(reason)" } } } +struct SessionStoreSnapshot { + let storePath: String + let defaults: SessionDefaults + let rows: [SessionRow] +} + +@MainActor enum SessionLoader { static let fallbackModel = "claude-opus-4-5" static let fallbackContextTokens = 200_000 @@ -206,194 +221,68 @@ enum SessionLoader { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".clawdis/sessions/sessions.json").path) - private static let legacyStorePaths: [String] = [ - standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json") - .path), - ] - - static func configHints() -> SessionConfigHints { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis/clawdis.json") - guard let data = try? Data(contentsOf: configURL) else { - return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) - } - guard let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) - } - - let inbound = parsed["inbound"] as? [String: Any] - let reply = inbound?["reply"] as? [String: Any] - let session = reply?["session"] as? [String: Any] - let agent = reply?["agent"] as? [String: Any] - - let store = session?["store"] as? String - let model = agent?["model"] as? String - let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue - - return SessionConfigHints( - storePath: store.map { self.standardize($0) }, - model: model, - contextTokens: contextTokens) - } - - static func resolveStorePath(override: String?) -> String { - let preferred = self.standardize(override ?? self.defaultStorePath) - let candidates = [preferred] + self.legacyStorePaths - if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) { - return existing - } - return preferred - } - - static func availableModels(storeOverride: String?) -> [String] { - let path = self.resolveStorePath(override: storeOverride) - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - else { - return [self.fallbackModel] - } - let models = decoded.values.compactMap(\.model) - return ([self.fallbackModel] + models).dedupedPreserveOrder() - } - - static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { - try await Task.detached(priority: .userInitiated) { - guard FileManager.default.fileExists(atPath: path) else { - throw SessionLoadError.missingStore(path) - } - - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - let decoded: [String: SessionEntryRecord] - do { - decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - } catch { - throw SessionLoadError.decodeFailed(error.localizedDescription) - } - - let storeDir = URL(fileURLWithPath: path).deletingLastPathComponent() - - return decoded.map { key, entry in - let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } - let input = entry.inputTokens ?? 0 - let output = entry.outputTokens ?? 0 - let fallbackTotal = entry.totalTokens ?? input + output - let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog( - sessionId: $0, - storeDir: storeDir) } - let total = max(fallbackTotal, promptTokens ?? 0) - let context = entry.contextTokens ?? defaults.contextTokens - let model = entry.model ?? defaults.model - - return SessionRow( - id: key, - key: key, - kind: SessionKind.from(key: key), - updatedAt: updated, - sessionId: entry.sessionId, - thinkingLevel: entry.thinkingLevel, - verboseLevel: entry.verboseLevel, - systemSent: entry.systemSent ?? false, - abortedLastRun: entry.abortedLastRun ?? false, - tokens: SessionTokenStats( - input: input, - output: output, - total: total, - contextTokens: context), - model: model) - } - .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - }.value - } - - private static func promptTokensFromSessionLog(sessionId: String, storeDir: URL) -> Int? { - let trimmed = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let candidates: [URL] = [ - storeDir.appendingPathComponent("\(trimmed).jsonl"), - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".pi/agent/sessions") - .appendingPathComponent("\(trimmed).jsonl"), - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".tau/agent/sessions/clawdis") - .appendingPathComponent("\(trimmed).jsonl"), + static func loadSnapshot( + activeMinutes: Int? = nil, + limit: Int? = nil, + includeGlobal: Bool = true, + includeUnknown: Bool = true) async throws -> SessionStoreSnapshot + { + var params: [String: AnyHashable] = [ + "includeGlobal": AnyHashable(includeGlobal), + "includeUnknown": AnyHashable(includeUnknown), ] + if let activeMinutes { params["activeMinutes"] = AnyHashable(activeMinutes) } + if let limit { params["limit"] = AnyHashable(limit) } - guard let logURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) else { - return nil + let data: Data + do { + data = try await ControlChannel.shared.request(method: "sessions.list", params: params) + } catch { + throw SessionLoadError.gatewayUnavailable(error.localizedDescription) } - guard let lastUsage = self.readLastUsageFromJsonl(logURL) else { return nil } + let decoded: GatewaySessionsListResponse + do { + decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data) + } catch { + throw SessionLoadError.decodeFailed(error.localizedDescription) + } - let input = self.number(from: lastUsage["input"]) ?? 0 - let output = self.number(from: lastUsage["output"]) ?? 0 - let cacheRead = self.number(from: lastUsage["cacheRead"] ?? lastUsage["cache_read"]) ?? 0 - let cacheWrite = self.number(from: lastUsage["cacheWrite"] ?? lastUsage["cache_write"]) ?? 0 - let totalTokens = self.number(from: lastUsage["totalTokens"] ?? lastUsage["total_tokens"] ?? lastUsage["total"]) + let defaults = SessionDefaults( + model: decoded.defaults?.model ?? self.fallbackModel, + contextTokens: decoded.defaults?.contextTokens ?? self.fallbackContextTokens) - let prompt = input + cacheRead + cacheWrite - if prompt > 0 { return prompt } - if let totalTokens, totalTokens > output { return totalTokens - output } - return nil + let rows = decoded.sessions.map { entry -> SessionRow in + let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } + let input = entry.inputTokens ?? 0 + let output = entry.outputTokens ?? 0 + let total = entry.totalTokens ?? input + output + let context = entry.contextTokens ?? defaults.contextTokens + let model = entry.model ?? defaults.model + + return SessionRow( + id: entry.key, + key: entry.key, + kind: SessionKind.from(key: entry.key), + updatedAt: updated, + sessionId: entry.sessionId, + thinkingLevel: entry.thinkingLevel, + verboseLevel: entry.verboseLevel, + systemSent: entry.systemSent ?? false, + abortedLastRun: entry.abortedLastRun ?? false, + tokens: SessionTokenStats( + input: input, + output: output, + total: total, + contextTokens: context), + model: model) + }.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + + return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows) } - private static func readLastUsageFromJsonl(_ url: URL) -> [String: Any]? { - // Logs can contain huge toolResult payloads (base64 images). Avoid parsing the whole file: - // read a tail window and scan backwards for the last JSON line that contains a usage blob. - let handle: FileHandle - do { - handle = try FileHandle(forReadingFrom: url) - } catch { - return nil - } - defer { try? handle.close() } - - let fileSize: UInt64 - do { - fileSize = try handle.seekToEnd() - } catch { - return nil - } - - let window: UInt64 = 512 * 1024 - let start = fileSize > window ? fileSize - window : 0 - do { - try handle.seek(toOffset: start) - } catch { - return nil - } - - let data = (try? handle.readToEnd()) ?? Data() - guard let text = String(data: data, encoding: .utf8) else { return nil } - - let lines = text.split(whereSeparator: \.isNewline) - for line in lines.reversed() { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedLine.isEmpty { continue } - // Cheap prefilter before JSON parsing. - if !trimmedLine.contains("\"usage\"") { continue } - guard let lineData = trimmedLine.data(using: .utf8) else { continue } - guard let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue } - - if let message = obj["message"] as? [String: Any], let usage = message["usage"] as? [String: Any] { - return usage - } - if let usage = obj["usage"] as? [String: Any] { - return usage - } - } - - return nil - } - - private static func number(from raw: Any?) -> Int? { - switch raw { - case let v as Int: v - case let v as Double: Int(v) - case let v as NSNumber: v.intValue - case let v as String: Int(v) - default: nil - } + static func loadRows() async throws -> [SessionRow] { + try await self.loadSnapshot().rows } private static func standardize(_ path: String) -> String { diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift index 8cc4f75e1..65ee5bdd1 100644 --- a/apps/macos/Sources/Clawdis/SessionsSettings.swift +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -196,20 +196,13 @@ struct SessionsSettings: View { self.loading = true self.errorMessage = nil - let hints = SessionLoader.configHints() - let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath) - let defaults = SessionDefaults( - model: hints.model ?? SessionLoader.fallbackModel, - contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) - do { - let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults) - self.rows = newRows - self.storePath = resolvedStore + let snapshot = try await SessionLoader.loadSnapshot() + self.rows = snapshot.rows + self.storePath = snapshot.storePath self.lastLoaded = Date() } catch { self.rows = [] - self.storePath = resolvedStore self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index ffccdcbd2..b3c0a5ec5 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -645,21 +645,9 @@ final class WebChatManager { } func preferredSessionKey() -> String { - // Prefer canonical main session; fall back to most recent. - let storePath = SessionLoader.defaultStorePath - if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)), - let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - { - if decoded.keys.contains("main") { return "main" } - - let sorted = decoded.sorted { a, b -> Bool in - let lhs = a.value.updatedAt ?? 0 - let rhs = b.value.updatedAt ?? 0 - return lhs > rhs - } - if let first = sorted.first { return first.key } - } - return "+1003" + // The gateway store uses a canonical direct-chat bucket (default: "main"). + // Avoid reading local session files; in remote mode they are not authoritative. + "main" } @MainActor