crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

main.swift (18152B)


      1 import Foundation
      2 
      3 enum Agent: String {
      4     case codex
      5     case claude
      6 }
      7 
      8 struct Options {
      9     var agent = Agent.codex
     10     var timeout: TimeInterval = 120
     11     var limit: Int?
     12     var dryRun = false
     13     var reviewCorrections = true
     14     var inputPath: String?
     15 }
     16 
     17 struct ClueEntry {
     18     let lineIndex: Int
     19     let label: String
     20     let answer: String
     21 }
     22 
     23 struct ClueIssue {
     24     let entry: ClueEntry
     25     let clue: String
     26     let reason: String
     27 }
     28 
     29 struct CluemakeError: Error, CustomStringConvertible {
     30     let description: String
     31 }
     32 
     33 var standardError = FileHandle.standardError
     34 
     35 func print<T>(_ value: T, to file: inout FileHandle) {
     36     let data = "\(value)\n".data(using: .utf8)!
     37     file.write(data)
     38 }
     39 
     40 func printUsage() {
     41     print("""
     42     Usage: Cluemake [options] XD_FILE
     43 
     44     Replaces XD clue text written as [No clue] with clues generated by a coding agent.
     45 
     46     Options:
     47       --agent codex|claude   Agent command to call. Default: codex.
     48       --timeout SECONDS      Agent timeout. Default: 120.
     49       --limit N              Generate at most N clues.
     50       --dry-run              Print the updated XD to stdout instead of writing the file.
     51       --skip-review          Skip the final agent review-and-correct pass.
     52       -h, --help             Show this help.
     53     """)
     54 }
     55 
     56 func parseOptions(_ arguments: [String]) throws -> Options {
     57     var options = Options()
     58     var index = 1
     59     var inputs: [String] = []
     60 
     61     func requireValue(_ name: String) throws -> String {
     62         guard index + 1 < arguments.count else {
     63             throw CluemakeError(description: "Missing value for \(name)")
     64         }
     65         index += 1
     66         return arguments[index]
     67     }
     68 
     69     while index < arguments.count {
     70         let argument = arguments[index]
     71         switch argument {
     72         case "--agent":
     73             let value = try requireValue(argument)
     74             guard let agent = Agent(rawValue: value) else {
     75                 throw CluemakeError(description: "--agent must be codex or claude")
     76             }
     77             options.agent = agent
     78         case "--timeout":
     79             let value = try requireValue(argument)
     80             guard let timeout = TimeInterval(value), timeout > 0 else {
     81                 throw CluemakeError(description: "--timeout must be a positive number")
     82             }
     83             options.timeout = timeout
     84         case "--limit":
     85             let value = try requireValue(argument)
     86             guard let limit = Int(value), limit > 0 else {
     87                 throw CluemakeError(description: "--limit must be a positive integer")
     88             }
     89             options.limit = limit
     90         case "--dry-run":
     91             options.dryRun = true
     92         case "--skip-review":
     93             options.reviewCorrections = false
     94         case "-h", "--help":
     95             printUsage()
     96             exit(0)
     97         default:
     98             inputs.append(argument)
     99         }
    100         index += 1
    101     }
    102 
    103     guard inputs.count == 1 else {
    104         throw CluemakeError(description: "Expected exactly one XD file")
    105     }
    106     options.inputPath = inputs[0]
    107     return options
    108 }
    109 
    110 func noClueEntries(in lines: [String]) -> [ClueEntry] {
    111     let noClueMarker = " [No clue] ~ "
    112 
    113     return lines.enumerated().compactMap { lineIndex, line -> ClueEntry? in
    114         guard let dotRange = line.firstIndex(of: "."),
    115               let first = line.first,
    116               first == "A" || first == "D",
    117               line[line.index(after: line.startIndex)..<dotRange].allSatisfy({ $0.isNumber }),
    118               line[line.index(after: dotRange)...].hasPrefix(noClueMarker) else {
    119             return nil
    120         }
    121 
    122         let label = String(line[...dotRange])
    123         let answerStart = line.index(line.index(after: dotRange), offsetBy: noClueMarker.count)
    124         let answer = String(line[answerStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
    125         guard !answer.isEmpty else {
    126             return nil
    127         }
    128         return ClueEntry(lineIndex: lineIndex, label: label, answer: answer)
    129     }
    130 }
    131 
    132 func prompt(for entries: [ClueEntry]) -> String {
    133     let answerLines = entries
    134         .map { "\($0.label) \($0.answer)" }
    135         .joined(separator: "\n")
    136 
    137     return """
    138     Write fair American-style crossword clues for these answers.
    139 
    140     Requirements:
    141     - Return only one line per answer, in the exact format "A1. Clue text".
    142     - Use each label exactly once.
    143     - Keep each clue concise, generally 2 to 8 words.
    144     - Do not include the answer in its clue.
    145     - Do not put the answer after the clue label.
    146     - Do not add parenthetical hints or extra explanations after the clue.
    147     - Avoid duplicate clue angles across the list.
    148     - Avoid fill-in-the-blank clues unless the answer is normally clued that way.
    149     - Prefer references from 1990 onward; use older references only when the answer strongly calls for them.
    150 
    151     Answers:
    152     \(answerLines)
    153     """
    154 }
    155 
    156 func correctionPrompt(for issues: [ClueIssue]) -> String {
    157     let issueLines = issues
    158         .map { "\($0.entry.label) \($0.entry.answer) | Current clue: \($0.clue) | Problem: \($0.reason)" }
    159         .joined(separator: "\n")
    160 
    161     return """
    162     Rewrite only these problematic crossword clues.
    163 
    164     Requirements:
    165     - Return only one corrected line per answer, in the exact format "A1. Clue text".
    166     - Use each label exactly once.
    167     - Keep each clue concise, generally 2 to 8 words.
    168     - Do not include the answer in its clue.
    169     - Do not put the answer after the clue label.
    170     - Do not add parenthetical hints or extra explanations after the clue.
    171     - Avoid fill-in-the-blank clues unless the answer is normally clued that way.
    172     - Prefer references from 1990 onward; use older references only when the answer strongly calls for them.
    173 
    174     Problematic clues:
    175     \(issueLines)
    176     """
    177 }
    178 
    179 func reviewPrompt(for entries: [ClueEntry], cluesByLabel: [String: String]) -> String {
    180     let clueLines = entries
    181         .compactMap { entry -> String? in
    182             guard let clue = cluesByLabel[entry.label] else {
    183                 return nil
    184             }
    185             return "\(entry.label) \(clue) ~ \(entry.answer)"
    186         }
    187         .joined(separator: "\n")
    188 
    189     return """
    190     Review these crossword clue/answer pairs for likely factual errors, answer-in-clue problems, unfair wording, parenthetical extra hints, or clues that point to a different answer.
    191 
    192     Requirements:
    193     - Return only corrected clue lines for entries that need changes, in the exact format "A1. Clue text".
    194     - If no corrections are needed, return only "NONE".
    195     - Do not explain the corrections.
    196     - Do not include the answer in its clue.
    197     - Do not add parenthetical hints or extra explanations after the clue.
    198     - Keep each replacement clue concise, generally 2 to 8 words.
    199 
    200     Clue/answer pairs:
    201     \(clueLines)
    202     """
    203 }
    204 
    205 func command(for agent: Agent, prompt: String) -> (String, [String]) {
    206     switch agent {
    207     case .codex:
    208         return ("codex", [
    209             "exec",
    210             "--ask-for-approval", "never",
    211             "--sandbox", "read-only",
    212             prompt
    213         ])
    214     case .claude:
    215         return ("claude", [
    216             "-p",
    217             "--tools", "",
    218             "--permission-mode", "dontAsk",
    219             prompt
    220         ])
    221     }
    222 }
    223 
    224 func readAvailableData(from pipe: Pipe) -> String {
    225     let data = pipe.fileHandleForReading.readDataToEndOfFile()
    226     return String(data: data, encoding: .utf8) ?? ""
    227 }
    228 
    229 func runAgent(_ agent: Agent, prompt: String, timeout: TimeInterval) throws -> String {
    230     let process = Process()
    231     let stdout = Pipe()
    232     let stderr = Pipe()
    233     let (program, arguments) = command(for: agent, prompt: prompt)
    234 
    235     process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
    236     process.arguments = [program] + arguments
    237     process.standardOutput = stdout
    238     process.standardError = stderr
    239 
    240     try process.run()
    241 
    242     let deadline = Date().addingTimeInterval(timeout)
    243     while process.isRunning && Date() < deadline {
    244         Thread.sleep(forTimeInterval: 0.1)
    245     }
    246 
    247     if process.isRunning {
    248         process.terminate()
    249         throw CluemakeError(description: "\(agent.rawValue) timed out after \(Int(timeout)) seconds")
    250     }
    251 
    252     let output = readAvailableData(from: stdout)
    253     let errorOutput = readAvailableData(from: stderr)
    254     guard process.terminationStatus == 0 else {
    255         let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines)
    256         throw CluemakeError(description: "\(agent.rawValue) exited with status \(process.terminationStatus): \(detail)")
    257     }
    258 
    259     guard !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
    260         let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines)
    261         throw CluemakeError(description: "\(agent.rawValue) returned an empty response. \(detail)")
    262     }
    263     return output
    264 }
    265 
    266 func sanitizeClue(_ output: String) -> String {
    267     var clue = output.trimmingCharacters(in: .whitespacesAndNewlines)
    268 
    269     if clue.hasPrefix("\""), clue.hasSuffix("\""), clue.count >= 2 {
    270         clue.removeFirst()
    271         clue.removeLast()
    272     }
    273     if clue.hasPrefix("'"), clue.hasSuffix("'"), clue.count >= 2 {
    274         clue.removeFirst()
    275         clue.removeLast()
    276     }
    277     return clue.trimmingCharacters(in: .whitespacesAndNewlines)
    278 }
    279 
    280 func parseClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] {
    281     let expectedLabels = Set(expectedEntries.map(\.label))
    282     var clues: [String: String] = [:]
    283 
    284     for rawLine in output.components(separatedBy: .newlines) {
    285         var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
    286         if line.hasPrefix("- ") || line.hasPrefix("* ") {
    287             line.removeFirst(2)
    288         }
    289 
    290         guard let dotIndex = line.firstIndex(of: ".") else {
    291             continue
    292         }
    293 
    294         let label = String(line[...dotIndex])
    295         guard expectedLabels.contains(label) else {
    296             continue
    297         }
    298 
    299         let clueStart = line.index(after: dotIndex)
    300         let clue = sanitizeClue(String(line[clueStart...]))
    301         guard !clue.isEmpty else {
    302             throw CluemakeError(description: "\(label) has an empty clue")
    303         }
    304         guard clues[label] == nil else {
    305             throw CluemakeError(description: "\(label) was returned more than once")
    306         }
    307         clues[label] = clue
    308     }
    309 
    310     let missing = expectedEntries.map(\.label).filter { clues[$0] == nil }
    311     guard missing.isEmpty else {
    312         throw CluemakeError(description: "Missing clue(s) for: \(missing.joined(separator: ", "))")
    313     }
    314 
    315     return clues
    316 }
    317 
    318 func parseCorrectionClues(_ output: String, expectedEntries: [ClueEntry]) throws -> [String: String] {
    319     if output.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "NONE" {
    320         return [:]
    321     }
    322 
    323     let expectedLabels = Set(expectedEntries.map(\.label))
    324     var clues: [String: String] = [:]
    325 
    326     for rawLine in output.components(separatedBy: .newlines) {
    327         var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
    328         if line.hasPrefix("- ") || line.hasPrefix("* ") {
    329             line.removeFirst(2)
    330         }
    331 
    332         guard let dotIndex = line.firstIndex(of: ".") else {
    333             continue
    334         }
    335 
    336         let label = String(line[...dotIndex])
    337         guard expectedLabels.contains(label) else {
    338             continue
    339         }
    340 
    341         let clueStart = line.index(after: dotIndex)
    342         let clue = sanitizeClue(String(line[clueStart...]))
    343         guard !clue.isEmpty else {
    344             throw CluemakeError(description: "\(label) has an empty corrected clue")
    345         }
    346         guard clues[label] == nil else {
    347             throw CluemakeError(description: "\(label) correction was returned more than once")
    348         }
    349         clues[label] = clue
    350     }
    351 
    352     return clues
    353 }
    354 
    355 func issues(in cluesByLabel: [String: String], entries: [ClueEntry]) -> [ClueIssue] {
    356     entries.compactMap { entry in
    357         guard let clue = cluesByLabel[entry.label] else {
    358             return nil
    359         }
    360 
    361         if containsParenthetical(in: clue) {
    362             return ClueIssue(entry: entry, clue: clue, reason: "contains a parenthetical hint")
    363         }
    364         if clueStartsWithAnswer(clue, answer: entry.answer) {
    365             return ClueIssue(entry: entry, clue: clue, reason: "starts with the answer")
    366         }
    367         if clueContainsAnswerAsWord(clue, answer: entry.answer) {
    368             return ClueIssue(entry: entry, clue: clue, reason: "contains the answer")
    369         }
    370         return nil
    371     }
    372 }
    373 
    374 func containsParenthetical(in clue: String) -> Bool {
    375     clue.contains("(") || clue.contains(")")
    376 }
    377 
    378 func normalized(_ text: String) -> String {
    379     text.uppercased()
    380 }
    381 
    382 func clueStartsWithAnswer(_ clue: String, answer: String) -> Bool {
    383     let normalizedClue = normalized(clue)
    384     let normalizedAnswer = normalized(answer)
    385     guard normalizedClue.hasPrefix(normalizedAnswer) else {
    386         return false
    387     }
    388 
    389     return isAnswerBoundary(in: normalizedClue, before: nil, afterAnswerAt: normalizedAnswer.count)
    390 }
    391 
    392 func clueContainsAnswerAsWord(_ clue: String, answer: String) -> Bool {
    393     let normalizedClue = clue.uppercased()
    394     let normalizedAnswer = answer.uppercased()
    395     var searchStart = normalizedClue.startIndex
    396 
    397     while let range = normalizedClue.range(of: normalizedAnswer, range: searchStart..<normalizedClue.endIndex) {
    398         let before = range.lowerBound == normalizedClue.startIndex ? nil : normalizedClue.index(before: range.lowerBound)
    399         if isAnswerBoundary(in: normalizedClue, before: before, afterAnswerAt: normalizedClue.distance(from: normalizedClue.startIndex, to: range.upperBound)) {
    400             return true
    401         }
    402         searchStart = range.upperBound
    403     }
    404 
    405     return false
    406 }
    407 
    408 func isAnswerBoundary(in text: String, before: String.Index?, afterAnswerAt offset: Int) -> Bool {
    409     if let before, text[before].isLetter {
    410         return false
    411     }
    412 
    413     guard offset < text.count else {
    414         return true
    415     }
    416 
    417     let after = text.index(text.startIndex, offsetBy: offset)
    418     return !text[after].isLetter
    419 }
    420 
    421 func correctedClues(
    422     for clueIssues: [ClueIssue],
    423     agent: Agent,
    424     timeout: TimeInterval
    425 ) throws -> [String: String] {
    426     guard !clueIssues.isEmpty else {
    427         return [:]
    428     }
    429 
    430     print("Correcting \(clueIssues.count) clue issue(s) with \(agent.rawValue)", to: &standardError)
    431     let entries = clueIssues.map(\.entry)
    432     let response = try runAgent(agent, prompt: correctionPrompt(for: clueIssues), timeout: timeout)
    433     let corrections = try parseClues(response, expectedEntries: entries)
    434     let remainingIssues = issues(in: corrections, entries: entries)
    435     guard remainingIssues.isEmpty else {
    436         let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ")
    437         throw CluemakeError(description: "Corrected clue(s) still have issue(s): \(labels)")
    438     }
    439     return corrections
    440 }
    441 
    442 func reviewedCorrections(
    443     entries: [ClueEntry],
    444     cluesByLabel: [String: String],
    445     agent: Agent,
    446     timeout: TimeInterval
    447 ) throws -> [String: String] {
    448     print("Reviewing clues for corrections with \(agent.rawValue)", to: &standardError)
    449     let response = try runAgent(agent, prompt: reviewPrompt(for: entries, cluesByLabel: cluesByLabel), timeout: timeout)
    450     let corrections = try parseCorrectionClues(response, expectedEntries: entries)
    451     let entriesByLabel = Dictionary(uniqueKeysWithValues: entries.map { ($0.label, $0) })
    452     let correctedEntries = corrections.keys.compactMap { entriesByLabel[$0] }
    453     let remainingIssues = issues(in: corrections, entries: correctedEntries)
    454     guard remainingIssues.isEmpty else {
    455         let labels = remainingIssues.map { "\($0.entry.label) (\($0.reason))" }.joined(separator: ", ")
    456         throw CluemakeError(description: "Reviewed correction(s) still have issue(s): \(labels)")
    457     }
    458     return corrections
    459 }
    460 
    461 func replaceNoClueLine(_ line: String, with clue: String) -> String {
    462     line.replacingOccurrences(of: "[No clue]", with: clue)
    463 }
    464 
    465 do {
    466     let options = try parseOptions(CommandLine.arguments)
    467     let inputPath = options.inputPath!
    468     let inputURL = URL(fileURLWithPath: inputPath)
    469     var isDirectory: ObjCBool = false
    470 
    471     guard FileManager.default.fileExists(atPath: inputPath, isDirectory: &isDirectory), !isDirectory.boolValue else {
    472         throw CluemakeError(description: "XD file not found: \(inputPath)")
    473     }
    474 
    475     let original = try String(contentsOf: inputURL, encoding: .utf8)
    476     var lines = original.components(separatedBy: .newlines)
    477     var entries = noClueEntries(in: lines)
    478     if let limit = options.limit {
    479         entries = Array(entries.prefix(limit))
    480     }
    481 
    482     guard !entries.isEmpty else {
    483         print("No [No clue] entries found.", to: &standardError)
    484         exit(0)
    485     }
    486 
    487     print("Cluing \(entries.count) answer(s) with \(options.agent.rawValue)", to: &standardError)
    488     let response = try runAgent(options.agent, prompt: prompt(for: entries), timeout: options.timeout)
    489     var cluesByLabel = try parseClues(response, expectedEntries: entries)
    490     let corrections = try correctedClues(
    491         for: issues(in: cluesByLabel, entries: entries),
    492         agent: options.agent,
    493         timeout: options.timeout
    494     )
    495     for (label, clue) in corrections {
    496         cluesByLabel[label] = clue
    497     }
    498     if options.reviewCorrections {
    499         let reviewCorrections = try reviewedCorrections(
    500             entries: entries,
    501             cluesByLabel: cluesByLabel,
    502             agent: options.agent,
    503             timeout: options.timeout
    504         )
    505         for (label, clue) in reviewCorrections {
    506             cluesByLabel[label] = clue
    507         }
    508     }
    509 
    510     for entry in entries {
    511         let clue = cluesByLabel[entry.label]!
    512         lines[entry.lineIndex] = replaceNoClueLine(lines[entry.lineIndex], with: clue)
    513     }
    514 
    515     let updated = lines.joined(separator: "\n")
    516     if options.dryRun {
    517         print(updated, terminator: "")
    518     } else {
    519         try updated.write(to: inputURL, atomically: true, encoding: .utf8)
    520         print("Updated \(entries.count) clue(s) in \(inputPath).", to: &standardError)
    521     }
    522 } catch {
    523     print("Cluemake: \(error)", to: &standardError)
    524     exit(1)
    525 }