File size: 4,291 Bytes
4977db2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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()
    }
}