import Foundation struct ClaudeJSONLParser: ImportParser { private static let agentPrefixes = [ "RESEARCH ONLY", "You are working", "Work in ~/", "Explore the", "Search system", "Check these", "Read ALL", "Find ALL", "do NOT modify", "Make ", "Run ", "Execute ", ] func parse(sourceURL: URL, dedupIndex: DedupIndex) throws -> ImportParseResult { let files = try ImportFiles.recursiveJSONLFiles(at: sourceURL, excluding: "subagents") var accumulator = ImportAccumulator( minimumLength: 20, maximumLength: 10_000, dedupIndex: dedupIndex ) for fileURL in files { let project = projectTag(from: fileURL) for line in try ImportFiles.lines(from: fileURL) { let root: Any do { root = try ImportJSON.object(fromLine: line) } catch { accumulator.skipMalformed() continue } guard let object = root as? [String: Any] else { accumulator.skipMalformed() continue } // Claude transcripts also contain valid system/snapshot events // without a message payload. They are not malformed prompts. guard let message = object["message"] as? [String: Any] else { continue } guard message["role"] as? String == "user" else { continue } // The legacy importer defaults a missing content field to an // empty string, so it counts as scanned + skipped_short. let text: String if message["content"] == nil { text = "" } else if let extracted = userText(from: message["content"]) { text = extracted } else { continue } let trimmed = ImportText.trimmed(text) if Self.agentPrefixes.contains(where: { trimmed.hasPrefix($0) }) { accumulator.skipAgent() continue } accumulator.accept( trimmed, sourcePrefix: "claude-raw", tags: "claude,\(project)", promptedAt: ImportTimestamp.normalize(object["timestamp"]) ) } } return accumulator.result() } private func userText(from content: Any?) -> String? { if let text = content as? String { return text } guard let blocks = content as? [Any] else { return nil } let textParts = blocks.compactMap { block -> String? in if let text = block as? String { return text } guard let object = block as? [String: Any], object["type"] as? String == "text" else { return nil } return object["text"] as? String ?? "" } return textParts.joined(separator: " ") } private func projectTag(from fileURL: URL) -> String { for component in fileURL.pathComponents { if let project = decodedProjectComponent(component, homeMarker: "-home-") { return project } if let project = decodedProjectComponent(component, homeMarker: "-Users-") { return project } } return "claude" } /// Claude encodes `/home/name/project` and `/Users/name/project` as one /// dash-prefixed path component. Strip the home marker and user name, as /// the original Linux importer did for `-home-smlflg-`. private func decodedProjectComponent(_ component: String, homeMarker: String) -> String? { guard component.hasPrefix(homeMarker) else { return nil } let remainder = component.dropFirst(homeMarker.count) guard let firstDash = remainder.firstIndex(of: "-") else { return nil } let projectStart = remainder.index(after: firstDash) guard projectStart < remainder.endIndex else { return nil } return remainder[projectStart...].lowercased() } }