diff --git a/Sources/AgentSyncCore/ClaudeAdapter.swift b/Sources/AgentSyncCore/ClaudeAdapter.swift index 3e09e05..e501eac 100644 --- a/Sources/AgentSyncCore/ClaudeAdapter.swift +++ b/Sources/AgentSyncCore/ClaudeAdapter.swift @@ -31,26 +31,18 @@ public struct ClaudeAdapter: Sendable { return nil } - let fallbackSessionID = url.deletingPathExtension().lastPathComponent - var sessionID = fallbackSessionID + let sessionID = claudeSessionID(url: url, objects: objects) var cwd = "" - var title: String? var model: String? var firstTimestamp: Date? var lastTimestamp: Date? var events: [CanonicalEvent] = [] for (index, object) in objects.enumerated() { - if let value = object.string("sessionId") { - sessionID = value - } - if cwd.isEmpty, let value = object.string("cwd") { + if let value = object.string("cwd"), + cwd.isEmpty || object.string("sessionId") == sessionID { cwd = value } - // ai-title lines recur as Claude re-titles the session; latest wins. - if let aiTitle = object.string("aiTitle") { - title = aiTitle - } let timestamp = DateCoding.parse(object.string("timestamp")) ?? firstTimestamp ?? Date(timeIntervalSince1970: 0) if firstTimestamp == nil, object.string("timestamp") != nil { @@ -90,7 +82,7 @@ public struct ClaudeAdapter: Sendable { let createdAt = firstTimestamp ?? events.first?.timestamp ?? Date() let updatedAt = lastTimestamp ?? events.last?.timestamp ?? createdAt - let resolvedTitle = cleanTitle(title) + let resolvedTitle = claudeSessionTitle(objects: objects, sessionID: sessionID) ?? cleanTitle(events.first(where: { $0.role == .user && !looksLikeInjectedContext($0.text) })?.text) ?? "Claude session \(sessionID)" @@ -255,6 +247,32 @@ public struct ClaudeAdapter: Sendable { } } +/// Forks can begin with copied records that still carry the parent's ID. +/// Native UUID filenames identify the session being read, regardless of history. +func claudeSessionID(url: URL, objects: [[String: JSONValue]]) -> String { + let filename = url.deletingPathExtension().lastPathComponent + if UUID(uuidString: filename) != nil { + return filename + } + return objects.reversed().compactMap { $0.string("sessionId") }.first ?? filename +} + +/// Explicit names win over automatic names. Prefer this session's metadata; +/// copied parent metadata is a fallback for forks that have not been named yet. +func claudeSessionTitle(objects: [[String: JSONValue]], sessionID: String) -> String? { + func title(in records: [[String: JSONValue]]) -> String? { + for key in ["customTitle", "aiTitle"] { + if let value = records.reversed().compactMap({ cleanTitle($0.string(key)) }).first { + return value + } + } + return nil + } + return title(in: objects.filter { $0.string("sessionId") == sessionID }) + ?? title(in: objects.filter { $0.string("sessionId") == nil }) + ?? title(in: objects) +} + private func renderClaudeQuestions(_ input: [String: JSONValue]) -> String? { guard let questions = input.array("questions") else { return nil diff --git a/Sources/AgentSyncCore/ClaudeTitleCache.swift b/Sources/AgentSyncCore/ClaudeTitleCache.swift new file mode 100644 index 0000000..9f50883 --- /dev/null +++ b/Sources/AgentSyncCore/ClaudeTitleCache.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Keeps repeated picker refreshes from streaming unchanged transcripts. +/// A cached entry also represents a successful scan that found no title. +final class ClaudeTitleCache: @unchecked Sendable { + private struct Stamp: Equatable { + let modifiedAt: Date + let size: Int + let sessionID: String + } + + private struct Entry { + let stamp: Stamp + let title: String? + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + private let capacity: Int + + init(capacity: Int = 512) { + self.capacity = max(1, capacity) + } + + func title(for url: URL, sessionID: String, load: () throws -> String?) throws -> String? { + func stamp() throws -> Stamp? { + // Use a fresh URL so resource-value caching cannot hide a change. + let values = try URL(fileURLWithPath: url.path).resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey] + ) + guard let modifiedAt = values.contentModificationDate, let size = values.fileSize else { + return nil + } + return Stamp(modifiedAt: modifiedAt, size: size, sessionID: sessionID) + } + + let before = try stamp() + if let before, let cached = lock.withLock({ entries[url.path] }), cached.stamp == before { + return cached.title + } + let title = try load() + // Never cache a failed read or a result from a file changing mid-scan. + if let before, before == (try stamp()) { + lock.withLock { + if entries[url.path] == nil, entries.count >= capacity { + entries.removeAll(keepingCapacity: true) + } + entries[url.path] = Entry(stamp: before, title: title) + } + } + return title + } +} diff --git a/Sources/AgentSyncCore/RecentSessionScanner.swift b/Sources/AgentSyncCore/RecentSessionScanner.swift index da650a3..3534b2e 100644 --- a/Sources/AgentSyncCore/RecentSessionScanner.swift +++ b/Sources/AgentSyncCore/RecentSessionScanner.swift @@ -114,6 +114,8 @@ public struct SessionPreview: Identifiable, Equatable, Sendable { } public enum RecentSessionScanner { + private static let claudeTitleCache = ClaudeTitleCache() + /// Newest-first previews across all providers. public static func scan( claudeHome: URL, @@ -331,15 +333,13 @@ public enum RecentSessionScanner { prefixObjects: [[String: JSONValue]], tailObjects: [[String: JSONValue]] ) -> SessionPreview { - let fallbackID = url.deletingPathExtension().lastPathComponent - var sessionID = fallbackID + let edgeObjects = prefixObjects + tailObjects + let sessionID = claudeSessionID(url: url, objects: edgeObjects) var cwd = PathEncoding.decodeClaudeProjectName(url.deletingLastPathComponent().lastPathComponent) - var title: String? var snippet: String? var models: [String] = [] for object in prefixObjects { - sessionID = object.string("sessionId") ?? sessionID cwd = object.string("cwd") ?? cwd if let message = object.object("message") { if let model = message.string("model"), !models.contains(model) { @@ -347,20 +347,22 @@ public enum RecentSessionScanner { } if snippet == nil, object.string("type") == "user", - let text = previewText(from: message["content"]), + let text = claudePromptPreview(from: message["content"]), !looksLikeInjectedContext(text) { snippet = text } } } - // ai-title lines recur through the file as Claude re-titles; the last - // one in the tail is current. - for object in (prefixObjects + tailObjects) { - if let aiTitle = object.string("aiTitle") { - title = aiTitle + // Fork prefixes may still describe the parent's working directory. + for object in edgeObjects where object.string("sessionId") == sessionID { + if let value = object.string("cwd") { + cwd = value } } - let sourceTitle = cleanTitle(title) + let sourceTitle = (try? claudeTitleCache.title(for: url, sessionID: sessionID) { + try readClaudeTitle(from: url, sessionID: sessionID) + }) + ?? claudeSessionTitle(objects: edgeObjects, sessionID: sessionID) return SessionPreview( provider: .claude, @@ -376,6 +378,83 @@ public enum RecentSessionScanner { ) } + /// Names may be buried between the preview edges. Stream the file without + /// decoding message bodies or retaining the transcript. Title records are + /// small; oversized lines are discarded until the next newline. + private static func readClaudeTitle(from url: URL, sessionID: String) throws -> String? { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let chunkSize = 64 * 1024 + let titleKeys = ["customTitle", "aiTitle"] + let markers = titleKeys.map { Data("\"\($0)\"".utf8) } + var line = Data() + var oversized = false + // At most six records: latest custom/automatic title in each scope + // (this session, unscoped, or copied parent history). + var latest: [String: (Int, [String: JSONValue])] = [:] + var recordIndex = 0 + + func consumeLine() { + defer { line.removeAll(keepingCapacity: true); oversized = false } + guard !oversized, markers.contains(where: { line.range(of: $0) != nil }), + let raw = try? JSONSerialization.jsonObject(with: line), + let value = try? JSONValue.foundation(raw), + let object = value.objectValue else { return } + let id = object.string("sessionId") + let scope = id == sessionID ? "own" : (id == nil ? "unscoped" : "parent") + for key in titleKeys where cleanTitle(object.string(key)) != nil { + latest["\(scope):\(key)"] = (recordIndex, object) + } + recordIndex += 1 + } + + while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty { + var start = chunk.startIndex + while start < chunk.endIndex { + let newline = chunk[start...].firstIndex(of: 0x0a) + let end = newline ?? chunk.endIndex + if !oversized { + if line.count + (end - start) <= chunkSize { + line.append(contentsOf: chunk[start.. String? { + // Filter individual content blocks before joining. Claude Desktop puts + // worktree reminders in a separate block before the human's request. + if case .array(let values) = value { + let text = values.compactMap { item -> String? in + let text: String? + if let object = item.objectValue { + guard object.string("type") == "text" else { return nil } + text = object.string("text") + } else { + text = item.stringValue + } + guard let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !looksLikeInjectedContext(text) else { return nil } + return text + }.joined(separator: " ") + return text.isEmpty ? nil : text + } + return previewText(from: value) + } + private static func codexPreview( url: URL, modifiedAt: Date, diff --git a/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift b/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift index 4ba90ef..c5bf95c 100644 --- a/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift +++ b/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift @@ -4,6 +4,163 @@ import Testing @Suite("Recent session scanner titles") struct RecentSessionScannerTests { + @Test("Claude title cache reuses missing titles and invalidates on size, modification time, and path") + func claudeTitleCacheInvalidation() throws { + try withClaudeProject { _, project in + let cache = ClaudeTitleCache() + let url = project.appendingPathComponent("session.jsonl") + let date = Date(timeIntervalSince1970: 1_000) + try Data("a".utf8).write(to: url) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + var reads = 0 + var recovered: String? + func read(_ file: URL) throws -> String? { + try cache.title(for: file, sessionID: "session") { + reads += 1 + return recovered + } + } + #expect(try read(url) == nil) + #expect(try read(url) == nil) + #expect(reads == 1) + + recovered = "Added title" + try Data("ab".utf8).write(to: url) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + #expect(try read(url) == "Added title") + #expect(reads == 2) + + recovered = "Renamed title" + try FileManager.default.setAttributes([.modificationDate: date.addingTimeInterval(1)], ofItemAtPath: url.path) + #expect(try read(url) == "Renamed title") + #expect(try read(url) == "Renamed title") + #expect(reads == 3) + + let copy = project.appendingPathComponent("copy.jsonl") + try FileManager.default.copyItem(at: url, to: copy) + #expect(try read(copy) == "Renamed title") + #expect(reads == 4) + } + } + + @Test("Claude title cache retries failed reads and files that changed while being scanned") + func claudeTitleCacheUnstableReads() throws { + try withClaudeProject { _, project in + let cache = ClaudeTitleCache() + let url = project.appendingPathComponent("session.jsonl") + try Data("a".utf8).write(to: url) + var reads = 0 + enum ReadError: Error { case failed } + do { + _ = try cache.title(for: url, sessionID: "session") { throw ReadError.failed } + Issue.record("Expected the read to fail") + } catch ReadError.failed {} + let first = try cache.title(for: url, sessionID: "session") { + reads += 1 + try Data("ab".utf8).write(to: url) + return "Stale title" + } + #expect(first == "Stale title") + let second = try cache.title(for: url, sessionID: "session") { + reads += 1 + return "Current title" + } + #expect(second == "Current title") + #expect(reads == 2) + } + } + + @Test("Claude custom titles in skipped middle records override edge automatic titles") + func claudeTitleInMiddle() throws { + try withClaudeProject { root, project in + let id = "11111111-1111-4111-8111-111111111111" + let url = project.appendingPathComponent("\(id).jsonl") + let filler = #"{"type":"assistant","message":{"content":"\#(String(repeating: "x", count: 2000))"}}"# + "\n" + let transcript = """ + {"type":"user","sessionId":"\(id)","message":{"content":"Original request"}} + {"type":"ai-title","sessionId":"\(id)","aiTitle":"Automatic title"} + + """ + String(repeating: filler, count: 180) + """ + {"type":"custom-title","sessionId":"\(id)","customTitle":"Old explicit title"} + {"type":"assistant","message":{"content":"\(String(repeating: "x", count: 300_000))"}} + {"type":"custom-title","sessionId":"\(id)","customTitle":"Latest explicit title"} + + """ + String(repeating: filler, count: 180) + try Data(transcript.utf8).write(to: url) + let preview = try #require(try scanClaudeProject(root).first) + let imported = try #require(try ClaudeAdapter().importSession(from: url)) + #expect(preview.title == "Latest explicit title") + #expect(preview.hasSourceTitle) + #expect(preview.title == imported.title) + } + } + + @Test("Claude forks keep their own identity and custom title despite copied parent history") + func claudeForkMetadata() throws { + try withClaudeProject { root, project in + let parent = "11111111-1111-4111-8111-111111111111" + let fork = "22222222-2222-4222-8222-222222222222" + let history = """ + {"type":"user","sessionId":"\(parent)","cwd":"/tmp/parent","message":{"content":"Original request"}} + {"type":"custom-title","sessionId":"\(parent)","customTitle":"Parent title"} + {"type":"assistant","sessionId":"\(parent)","message":{"content":"\(String(repeating: "x", count: 300_000))"}} + + """ + try Data(history.utf8).write(to: project.appendingPathComponent("\(parent).jsonl")) + let forkURL = project.appendingPathComponent("\(fork).jsonl") + try Data((history + """ + {"type":"custom-title","sessionId":"\(fork)","customTitle":"Fork title"} + {"type":"ai-title","sessionId":"\(fork)","aiTitle":"Automatic title"} + {"type":"user","sessionId":"\(fork)","cwd":"/tmp/fork","message":{"content":"Try a different approach"}} + + """).utf8).write(to: forkURL) + let previews = try scanClaudeProject(root) + #expect(previews.count == 2) + let preview = try #require(previews.first { $0.sessionID == fork }) + #expect(preview.title == "Fork title") + #expect(preview.hasSourceTitle) + #expect(preview.cwd == "/tmp/fork") + let imported = try #require(try ClaudeAdapter().importSession(from: forkURL)) + #expect(imported.sourceSessionID == fork) + #expect(imported.title == "Fork title") + #expect(imported.cwd == "/tmp/fork") + } + } + + @Test("Claude prompt fallback keeps human text after an injected content block") + func claudePromptAfterInjectedBlock() throws { + try withClaudeProject { root, project in + let id = "11111111-1111-4111-8111-111111111111" + let url = project.appendingPathComponent("\(id).jsonl") + let transcript = #"{"type":"user","sessionId":"\#(id)","message":{"content":[{"type":"text","text":"Worktree context"},{"type":"text","text":"Fix the session picker"}]}}"# + "\n" + try Data(transcript.utf8).write(to: url) + let preview = try #require(try scanClaudeProject(root).first) + #expect(preview.title == "Fix the session picker") + #expect(preview.snippet == "Fix the session picker") + #expect(!preview.hasSourceTitle) + let imported = try #require(try ClaudeAdapter().importSession(from: url)) + #expect(imported.title == preview.title) + } + } + + private func withClaudeProject(_ body: (URL, URL) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let project = root.appendingPathComponent("claude/projects/project") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try body(root, project) + } + + private func scanClaudeProject(_ root: URL) throws -> [SessionPreview] { + try RecentSessionScanner.scan( + claudeHome: root.appendingPathComponent("claude"), + codexHome: root.appendingPathComponent("codex"), + opencodeHome: root.appendingPathComponent("opencode"), + lookbackDays: nil, + maximumPerProvider: 10 + ) + } + @Test("duplicate Claude transcript copies produce one picker identity") func duplicateClaudeTranscriptCopies() throws { let root = FileManager.default.temporaryDirectory