From e7ca7cdfa79f4ddcdc9bd08e4478cd9e78073a92 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:30:22 -0700 Subject: [PATCH] Session Cache Implementation Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic --- .../NucleicRemote/Models/SessionCache.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Models/SessionCache.swift diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift new file mode 100644 index 0000000..7c27e16 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -0,0 +1,121 @@ +import Foundation +import NucleicProtocol + +/// An on-device cache of the phone's recent chat history — the session list and the transcripts of +/// the sessions the user has actually opened — so the app can show a read-only history while it's +/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical +/// state: a live host's data always supersedes it, and it's written straight from the same wire +/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented. +/// +/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the +/// OS may purge under storage pressure): +/// +/// NucleicSessionCache/summaries.json — the merged session list, recency-capped + pruned +/// NucleicSessionCache/transcripts/.json — one opened session's event tail +/// +/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing +/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary +/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole +/// history on every event, and cap the tail so a long run can't grow unbounded. +enum SessionCache { + /// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript + /// files) are pruned on every save so the cache stays bounded. + private static let sessionLimit = 50 + /// Keep the newest N events per session — the tail is what a returning reader wants, and it + /// bounds a long-running session's file. + private static let eventLimit = 1500 + + // MARK: - Paths + + private static var root: URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + else { return nil } + return base.appendingPathComponent("NucleicSessionCache", isDirectory: true) + } + private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") } + private static var transcriptsDir: URL? { + root?.appendingPathComponent("transcripts", isDirectory: true) + } + private static func transcriptURL(_ id: SessionID) -> URL? { + transcriptsDir?.appendingPathComponent(transcriptFileName(id)) + } + private static func transcriptFileName(_ id: SessionID) -> String { + // Session ids are UUID strings today, but sanitize defensively so an id can never escape + // the cache directory or collide with the summaries file. + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json" + } + + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + // MARK: - Summaries + + /// Persist the recent session list (recency-capped) and prune transcript files whose session is + /// no longer cached. Off the main actor. + static func saveSummaries(_ summaries: [WireSessionSummary]) async { + await Task.detached(priority: .utility) { writeSummaries(summaries) }.value + } + + /// Load the cached session list — called once at launch to seed the list before any host + /// connects. Small (≤ `sessionLimit`), so a synchronous read is fine. + static func loadSummaries() -> [WireSessionSummary] { + guard let url = summariesURL, let data = try? Data(contentsOf: url), + let list = try? decoder.decode([WireSessionSummary].self, from: data) + else { return [] } + return list + } + + private static func writeSummaries(_ summaries: [WireSessionSummary]) { + guard let root, let url = summariesURL, let dir = transcriptsDir else { return } + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit)) + if let data = try? encoder.encode(kept) { try? data.write(to: url, options: .atomic) } + // Drop transcript files for sessions that fell out of the recent set. + let keep = Set(kept.map(transcriptFileName)) + if let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil) { + for file in files where !keep.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } + } + } + + // MARK: - Transcripts + + /// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor. + static func saveEvents(_ events: [AgentEvent], for id: SessionID) async { + await Task.detached(priority: .utility) { writeEvents(events, id) }.value + } + + /// Load a session's cached transcript — used to seed the open transcript instantly, before the + /// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor. + static func loadEvents(_ id: SessionID) async -> [AgentEvent] { + await Task.detached(priority: .utility) { readEvents(id) }.value + } + + private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) { + guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return } + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let sorted = events.sorted { $0.seq < $1.seq } + let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted + if let data = try? encoder.encode(tail) { try? data.write(to: url, options: .atomic) } + } + + private static func readEvents(_ id: SessionID) -> [AgentEvent] { + guard let url = transcriptURL(id), let data = try? Data(contentsOf: url), + let events = try? decoder.decode([AgentEvent].self, from: data) + else { return [] } + return events + } + + // MARK: - Clear + + /// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this + /// device should hold. + static func clear() { + guard let root else { return } + try? FileManager.default.removeItem(at: root) + } +}