diff --git a/desktop/macos/Desktop/Package.swift b/desktop/macos/Desktop/Package.swift index a7ea36540b5..5a6f8e47ec5 100644 --- a/desktop/macos/Desktop/Package.swift +++ b/desktop/macos/Desktop/Package.swift @@ -16,6 +16,10 @@ let package = Package( .package( url: "https://github.com/microsoft/onnxruntime-swift-package-manager.git", from: "1.20.0"), .package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.14.8"), + // Prebuilt TDLib (Telegram) client for macOS arm64. This tag bundles TDLib 1.8.65, + // recent enough to avoid Telegram's UPDATE_APP_TO_LOGIN rejection. Tags carry a + // -tdlib-- suffix (not plain semver), so pin the exact tag. + .package(url: "https://github.com/Swiftgram/TDLibKit", exact: "1.5.2-tdlib-1.8.65-a17f87c4"), ], targets: [ .target( @@ -45,6 +49,7 @@ let package = Package( .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "onnxruntime", package: "onnxruntime-swift-package-manager"), .product(name: "FluidAudio", package: "FluidAudio"), + .product(name: "TDLibKit", package: "TDLibKit"), ], path: "Sources", resources: [ diff --git a/desktop/macos/Desktop/Sources/AICloneBacktestService.swift b/desktop/macos/Desktop/Sources/AICloneBacktestService.swift new file mode 100644 index 00000000000..8a9b5ff5c88 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneBacktestService.swift @@ -0,0 +1,505 @@ +import Foundation + +// MARK: - Models + +/// One held-out test case: what the contact said, what the user actually replied, what the +/// clone predicted, and how convincingly (LLM-judge score) the prediction passes as real. +struct BacktestPair: Sendable, Identifiable { + let id = UUID() + let contactMessage: String + let actualReply: String + /// When the contact's turn started — used to key this exact historical instance so + /// retrieval can exclude it (and only it) while predicting its held-out reply. + var turnDate: Date = Date(timeIntervalSinceReferenceDate: 0) + /// The few messages (both directions, oldest first) that preceded `contactMessage`. + var context: [ConversationTurn] = [] + var predictedReply: String? + /// LLM-judge score normalized to 0–1 (judge rates 0–100 for how convincingly the + /// predicted reply passes as the same person; we store /100). + var similarityScore: Double? + /// The judge's one-sentence justification for the score. + var judgeReasoning: String? +} + +/// The outcome of a backtest run (one iteration) or the best iteration of a training loop. +struct BacktestResult: Sendable { + let contactId: String + var pairs: [BacktestPair] + var averageScore: Double + let messageCountUsed: Int + /// For `runBacktest` this is 1. For `trainToTarget` it's the total iterations executed. + var iterationsRun: Int +} + +/// Progress ticks emitted during `trainToTarget`, for the UI to render live status. +struct BacktestProgress: Sendable { + let iteration: Int + let maxIterations: Int + let phase: String + let latestAverage: Double? +} + +enum BacktestError: LocalizedError { + case notEnoughData + case judgeParseFailed + + var errorDescription: String? { + switch self { + case .notEnoughData: + return "Not enough back-and-forth history with this contact to run a backtest." + case .judgeParseFailed: + return "The scoring judge returned an unreadable response." + } + } +} + +// MARK: - Service + +/// Scores an AI Clone persona against real message history and iteratively refines it toward +/// a target accuracy. Each held-out reply is scored by an LLM judge (0–100, normalized to +/// 0–1) rating how convincingly the clone's prediction passes as the same person's real text. +actor AICloneBacktestService { + static let shared = AICloneBacktestService() + + private init() {} + + // MARK: Backtest (Part 2) + + /// Score `persona` against `holdoutCount` real (their-message → my-reply) pairs that were + /// NOT used as training examples. `messages` are any platform, newest-first. Returns the + /// per-pair predictions/scores and the average. + /// + /// - `seed`: deterministic holdout sampling (same pairs every run for fair comparison). + /// - `pinnedPairs`: evaluate exactly these (them → actual) pairs instead of sampling — + /// lets different architectures be scored on an identical eval set across runs. + /// - `excludePairKeys`: text-keys never to sample (e.g. the fixed eval set, during + /// training iterations, so refinement can't memorize eval answers). + func runBacktest( + for contact: ImportedContact, messages: [ImportedMessage], persona: ContactPersona, + holdoutCount: Int = 8, seed: UInt64? = nil, + pinnedPairs: [(them: String, me: String)]? = nil, + excludePairKeys: Set = [] + ) async throws -> BacktestResult { + let chronological = Array(messages.reversed()) + + // The retrieval index must exist before respond() so dynamic few-shots work even when + // the persona was loaded from disk in a fresh session. + await AICloneRetrievalService.shared.ensureIndex(contactId: contact.id, messages: messages) + + // Extract real turn-pairs, then drop any that duplicate the persona's few-shot examples + // so we never test on data the model was trained on. + let trainingKeys = Set(persona.exampleExchanges.map { Self.pairKey(them: $0.them, me: $0.me) }) + let allPairs = Self.buildPairs(from: chronological) + let pairs = allPairs.filter { + let key = Self.pairKey(them: $0.contactMessage, me: $0.actualReply) + return !trainingKeys.contains(key) && !excludePairKeys.contains(key) + } + guard !pairs.isEmpty else { throw BacktestError.notEnoughData } + + var sampled: [BacktestPair] + if let pinnedPairs { + let wanted = Set(pinnedPairs.map { Self.pairKey(them: $0.them, me: $0.me) }) + var seen = Set() + sampled = allPairs.filter { pair in + let key = Self.pairKey(them: pair.contactMessage, me: pair.actualReply) + return wanted.contains(key) && seen.insert(key).inserted + } + guard sampled.count >= max(1, pinnedPairs.count / 2) else { + throw BacktestError.notEnoughData + } + } else { + // With a seed the holdout is deterministic (same pairs every run) so different + // architectures/personas are compared on identical data instead of fresh noise. + sampled = Array(Self.sample(pairs, count: holdoutCount, seed: seed)) + } + + // Predict the user's reply for each held-out contact message. Sequential on purpose — + // each respond() spins up its own agent bridge against the shared runtime, so we avoid + // overlapping requests. Each pair excludes ITS OWN historical instance from retrieval + // so the clone is never handed the exact answer it's being tested on. + for index in sampled.indices { + let pair = sampled[index] + let leakKey = AICloneRetrievalService.instanceKey( + them: pair.contactMessage, me: pair.actualReply, date: pair.turnDate) + do { + // Live context (today's memories/calendar) stays OFF here: the eval replays + // historical exchanges, where current-world facts are anachronistic noise. + sampled[index].predictedReply = try await AIClonePersonaService.shared.respond( + as: persona, to: pair.contactMessage, context: pair.context, + excludingPairKeys: [leakKey], includeLiveContext: false) + } catch { + log("AICloneBacktest: prediction failed for a pair: \(error)") + sampled[index].predictedReply = nil + } + } + + let scored = try await scorePairs(sampled) + let validScores = scored.compactMap { $0.similarityScore } + let average = validScores.isEmpty ? 0 : validScores.reduce(0, +) / Double(validScores.count) + + return BacktestResult( + contactId: contact.id, + pairs: scored, + averageScore: average, + messageCountUsed: messages.count, + iterationsRun: 1 + ) + } + + /// Score each pair with an LLM judge on VOICE plausibility (not topic match): would this + /// specific person plausibly send the predicted reply in this exact spot? Sets + /// `similarityScore` (0–1) and `judgeReasoning` (which is tagged with the topic-match verdict + /// so we can tell voice-match-without-topic-match apart from a true miss). + private func scorePairs(_ pairs: [BacktestPair]) async throws -> [BacktestPair] { + var result = pairs + for index in result.indices { + guard let predicted = result[index].predictedReply, + !predicted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { continue } + do { + let verdict = try await judge( + them: result[index].contactMessage, + actual: result[index].actualReply, + predicted: predicted, + context: result[index].context) + result[index].similarityScore = verdict.score / 100.0 + // Prefix the reasoning with the structured topic-match tag so results (and refinement) + // can distinguish "in-voice topic jump" from "generic on-topic but off-voice". + let tag = verdict.topicMatch ? "[topic match]" : "[topic CHANGED]" + result[index].judgeReasoning = "\(tag) \(verdict.reasoning)" + } catch { + log("AICloneBacktest: judge scoring failed for a pair: \(error)") + } + } + return result + } + + /// Score one (them, actual, predicted) triple with the standard judge — exposed for the + /// harness so stored predictions can be re-judged (variance measurement) without + /// re-predicting. Returns the 0–1 normalized score. + func judgeOnce( + them: String, actual: String, predicted: String, context: [ConversationTurn] + ) async throws -> (score: Double, reasoning: String, topicMatch: Bool) { + let verdict = try await judge(them: them, actual: actual, predicted: predicted, context: context) + return (verdict.score / 100.0, verdict.reasoning, verdict.topicMatch) + } + + /// Ask the model to rate VOICE plausibility (not topic match) 0–100, plus whether the + /// prediction matched the real reply's topic and a one-sentence justification. + private func judge( + them: String, actual: String, predicted: String, context: [ConversationTurn] + ) async throws -> (score: Double, reasoning: String, topicMatch: Bool) { + let systemPrompt = + "You are a strict judge of texting-STYLE impersonation. You evaluate whether an AI's " + + "predicted reply could plausibly be the SAME person's real next text — judging VOICE, " + + "not topic. Output only valid JSON." + + let contextBlock = + context.isEmpty + ? "" + : "Conversation leading up to this (oldest first):\n" + + context.map { "\($0.isFromMe ? "Them-person(you're imitating)" : "Contact"): \($0.text)" } + .joined(separator: "\n") + "\n\n" + + let prompt = """ + \(contextBlock)The contact just said: + "\(them)" + + The person you're imitating REALLY replied with: + "\(actual)" + + An AI clone predicted this reply instead: + "\(predicted)" + + Rate 0–100 how plausibly THIS SPECIFIC PERSON could have sent the predicted reply in this \ + exact spot — judging VOICE, not topic. This person often changes topic abruptly, drops \ + non-sequiturs, and goes on their own tangents, so a predicted reply does NOT need to match \ + the real reply's TOPIC or CONTENT to score well. + + SCORE HIGH when the prediction nails their voice — tone, slang/vocabulary, capitalization, \ + punctuation, emoji use, brevity, and multi-message burst style — EVEN IF it's about a \ + totally different topic than the real reply (an in-voice topic-jump is authentic to them). + + SCORE LOW when the prediction sounds generic, too formal, too long/short, or off-voice — \ + EVEN IF it's on the exact same topic as the real reply. Correct topic in the wrong voice \ + is a failure; wrong topic in the right voice is a success. + + Also report whether the prediction happened to match the real reply's topic/content. + + Respond ONLY with valid JSON (no markdown, no code fences): + {"score": , "topic_match": , "reasoning": ""} + """ + + let maxAttempts = 2 + var lastError: Error? + for attempt in 1...maxAttempts { + do { + let bridge = AgentBridge(harnessMode: "piMono") + try await bridge.start() + defer { Task { await bridge.stop() } } + + let response = try await bridge.query( + prompt: prompt, + systemPrompt: systemPrompt, + model: ModelQoS.Claude.synthesis, + onTextDelta: { @Sendable _ in }, + onToolCall: { @Sendable _, _, _ in "" }, + onToolActivity: { @Sendable _, _, _, _ in } + ) + + let jsonText = Self.extractJSONObject(from: response.text) + guard + let data = jsonText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw BacktestError.judgeParseFailed + } + + let rawScore = (parsed["score"] as? Double) ?? Double(parsed["score"] as? Int ?? -1) + guard rawScore >= 0 else { throw BacktestError.judgeParseFailed } + let clamped = min(100, max(0, rawScore)) + let reasoning = (parsed["reasoning"] as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let topicMatch = (parsed["topic_match"] as? Bool) ?? false + return (clamped, reasoning, topicMatch) + } catch { + lastError = error + if attempt < maxAttempts { + try? await Task.sleep(nanoseconds: 600_000_000) + continue + } + } + } + throw lastError ?? BacktestError.judgeParseFailed + } + + // MARK: Training loop (Part 3) + + /// Generate a persona, backtest it, and refine toward `targetScore`, keeping the + /// best-scoring persona seen across all iterations. Returns that best persona/result + /// even if the target wasn't reached (loop hits `maxIterations`). Persists the winner. + func trainToTarget( + for contact: ImportedContact, + messages: [ImportedMessage], + // 0.80 is a deliberate calibration for the voice-plausibility rubric, not a placeholder: + // real single-persona runs show that ~0.30 content-match cosine and even a strict topic + // judge cap far below 0.95, because authentic texting includes unpredictable topic jumps. + // Under the voice-not-topic judge, ~0.80 average is a genuinely strong, achievable bar. + targetScore: Double = 0.80, + maxIterations: Int = 5, + holdoutCount: Int = 8, + excludePairKeys: Set = [], + onProgress: (@Sendable (BacktestProgress) -> Void)? = nil + ) async throws -> (persona: ContactPersona, result: BacktestResult) { + func tick(_ iteration: Int, _ phase: String, _ avg: Double?) { + onProgress?( + BacktestProgress( + iteration: iteration, maxIterations: maxIterations, phase: phase, latestAverage: avg)) + } + + tick(1, "Generating persona", nil) + var current = try await AIClonePersonaService.shared.generatePersona( + for: contact, messages: messages, excludeExchangeKeys: excludePairKeys) + + var best: (persona: ContactPersona, result: BacktestResult)? + var reachedTarget = false + var totalIterations = 0 + + for iteration in 1...maxIterations { + totalIterations = iteration + tick(iteration, "Backtesting", best?.result.averageScore) + let result = try await runBacktest( + for: contact, messages: messages, persona: current, holdoutCount: holdoutCount, + excludePairKeys: excludePairKeys) + + if best == nil || result.averageScore > best!.result.averageScore { + best = (current, result) + } + tick(iteration, "Scored", result.averageScore) + + if result.averageScore >= targetScore { + reachedTarget = true + log( + "AICloneBacktest: target \(targetScore) reached at iteration \(iteration) " + + "(score \(String(format: "%.3f", result.averageScore)))") + break + } + + guard iteration < maxIterations else { break } + + tick(iteration, "Refining persona", result.averageScore) + let worst = Self.worstPairs(from: result, limit: 4) + do { + current = try await AIClonePersonaService.shared.refinePersona( + for: contact, messages: messages, previous: current, worstPairs: worst, + excludeExchangeKeys: excludePairKeys) + } catch { + log("AICloneBacktest: refine failed at iteration \(iteration), stopping: \(error)") + break + } + } + + guard var winner = best else { throw BacktestError.notEnoughData } + winner.result.iterationsRun = totalIterations + + // Persist the best persona as the active one so Preview Chat / respond use it. + await AIClonePersonaService.shared.store(winner.persona) + + if reachedTarget { + log( + "AICloneBacktest: DONE — target reached, final avg " + + "\(String(format: "%.3f", winner.result.averageScore)) after \(totalIterations) iteration(s)") + } else { + log( + "AICloneBacktest: DONE — hit maxIterations \(maxIterations) without reaching " + + "\(targetScore); best avg \(String(format: "%.3f", winner.result.averageScore))") + } + + return winner + } + + // MARK: - Pair extraction & helpers + + /// How many preceding messages to attach as conversation context per pair. + private static let contextWindow = 10 + + /// A "my reply" only counts as a reply if it lands within this window of the contact's + /// last message. Beyond it, the from-me run is me starting a new conversation, not + /// answering — pairing those poisons both training examples and the holdout. + private static let replyGapLimit: TimeInterval = 4 * 60 * 60 + + /// Caps on how many bubbles of a run to keep (very long runs are monologues; keeping + /// the boundary-adjacent bubbles preserves the actual stimulus/response). + private static let maxThemBubbles = 6 + private static let maxMeBubbles = 8 + + /// Walk the chronological transcript and emit (their-run → my-run) pairs. Consecutive + /// messages from the same sender are concatenated (multi-bubble bursts become one turn). + /// Each pair also carries up to `contextWindow` preceding messages as context. + static func buildPairs(from chronological: [ImportedMessage]) -> [BacktestPair] { + var pairs: [BacktestPair] = [] + var index = 0 + let count = chronological.count + + while index < count { + // Skip until a contact (not-from-me) message starts a turn. + guard !chronological[index].isFromMe else { + index += 1 + continue + } + + let themStart = index + var themParts: [String] = [] + var themLastDate = chronological[index].date + while index < count && !chronological[index].isFromMe { + themParts.append(clean(chronological[index].text)) + themLastDate = chronological[index].date + index += 1 + } + + guard index < count, chronological[index].isFromMe else { continue } + + // If my first message comes hours later, it's a new conversation I started — skip. + guard chronological[index].date.timeIntervalSince(themLastDate) <= replyGapLimit else { + continue + } + + var meParts: [String] = [] + while index < count && chronological[index].isFromMe { + meParts.append(clean(chronological[index].text)) + index += 1 + } + + let them = themParts.suffix(maxThemBubbles) + .joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + let me = meParts.prefix(maxMeBubbles) + .joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + // A reply that is only attachment placeholders can't be predicted from text — skip. + let meIsOnlyAttachments = meParts.allSatisfy { $0 == "[attachment]" || $0.isEmpty } + if !them.isEmpty, !me.isEmpty, !meIsOnlyAttachments { + // Context = the messages immediately before this contact turn, oldest first. + let contextSlice = chronological[max(0, themStart - contextWindow).. ConversationTurn? in + let text = clean(message.text) + return text.isEmpty ? nil : ConversationTurn(isFromMe: message.isFromMe, text: text) + } + pairs.append( + BacktestPair( + contactMessage: them, actualReply: me, + turnDate: chronological[themStart].date, context: context)) + } + } + + return pairs + } + + /// Deterministically sample `count` pairs when `seed` is provided (SplitMix64-driven + /// Fisher–Yates), otherwise fall back to system randomness. + private static func sample(_ pairs: [BacktestPair], count: Int, seed: UInt64?) -> [BacktestPair] { + guard let seed else { return Array(pairs.shuffled().prefix(count)) } + var rng = SplitMix64(seed: seed) + var indices = Array(pairs.indices) + for i in stride(from: indices.count - 1, to: 0, by: -1) { + let j = Int(rng.next() % UInt64(i + 1)) + indices.swapAt(i, j) + } + return indices.prefix(count).map { pairs[$0] } + } + + /// The lowest-scoring predicted pairs (with the judge's reasoning), for refinement. + private static func worstPairs( + from result: BacktestResult, limit: Int + ) -> [(contactMessage: String, predicted: String, actual: String, reasoning: String)] { + result.pairs + .filter { $0.predictedReply != nil && $0.similarityScore != nil } + .sorted { ($0.similarityScore ?? 1) < ($1.similarityScore ?? 1) } + .prefix(limit) + .map { ($0.contactMessage, $0.predictedReply ?? "", $0.actualReply, $0.judgeReasoning ?? "") } + } + + private static func clean(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Tiny deterministic RNG (SplitMix64) for reproducible holdout sampling. + struct SplitMix64 { + private var state: UInt64 + init(seed: UInt64) { state = seed } + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } + + static func pairKey(them: String, me: String) -> String { + func norm(_ s: String) -> String { + s.lowercased() + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return norm(them) + "|||" + norm(me) + } + + /// Extract the leading JSON object from a model response (strip code fences / prose). + private static func extractJSONObject(from text: String) -> String { + var responseText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if responseText.hasPrefix("```") { + if let firstNewline = responseText.firstIndex(of: "\n") { + responseText = String(responseText[responseText.index(after: firstNewline)...]) + } + if responseText.hasSuffix("```") { + responseText = String(responseText.dropLast(3)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + if let braceIndex = responseText.firstIndex(of: "{") { + responseText = String(responseText[braceIndex...]) + } + return responseText.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneChatHarness.swift b/desktop/macos/Desktop/Sources/AICloneChatHarness.swift new file mode 100644 index 00000000000..f39805066f3 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneChatHarness.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Shared mailbox between the automation bridge and the AI Clone chat sheet: the live tab +/// publishes a state snapshot here so harness actions can verify the real UI without +/// accessibility access or cursor input. Only readable through the local bridge, which is +/// enabled on non-production bundles only. +@MainActor +final class AICloneChatAutomation { + static let shared = AICloneChatAutomation() + /// Latest state published by the open live chat tab; nil when no chat sheet is open. + var liveSnapshot: [String: String]? + /// The contact whose chat sheet is currently presented (authoritative, owned by + /// `AIClonePage`). The live tab's poll loop and notification handlers gate on this so a + /// dismissed-but-cached sheet view (macOS keeps sheet hosts around for reuse) can never + /// keep polling or react to triggers. + var activeContactId: String? +} + +/// Headless automation actions for the AI Clone chat sheet (live conversation + practice). +/// They post the same notifications / read the same state as the real controls, so they +/// exercise the genuine UI code paths. +/// +/// Actions: +/// ai_clone_open_chat contact_id=… — navigate to AI Clone and open the chat sheet +/// ai_clone_chat_state — dump the live tab's state (transcript, suggestion) +/// ai_clone_chat_suggest — trigger "Suggest reply" in the open live tab +/// ai_clone_chat_close — close the chat sheet +enum AICloneChatHarness { + + @MainActor + static func register(on registry: DesktopAutomationActionRegistry) { + registry.register( + name: "ai_clone_open_chat", + summary: "Open the AI Clone chat sheet (live conversation) for a trained contact", + params: ["contact_id"] + ) { params in + guard let contactId = params["contact_id"], !contactId.isEmpty else { + return ["error": "missing 'contact_id'"] + } + // Same route as omi-ctl navigate, but without activating the app so verification + // never steals the user's focus or Space. + NotificationCenter.default.post( + name: .desktopAutomationNavigateRequested, object: nil, + userInfo: ["target": "ai_clone"]) + NotificationCenter.default.post( + name: .aiCloneOpenChatRequested, object: nil, userInfo: ["contactId": contactId]) + return [ + "requested": contactId, + "note": "sheet opens once the page has loaded; poll ai_clone_chat_state", + ] + } + + registry.register( + name: "ai_clone_chat_state", + summary: "Dump the open AI Clone live chat tab's state (transcript tail + suggestion)" + ) { _ in + AICloneChatAutomation.shared.liveSnapshot ?? ["open": "false"] + } + + registry.register( + name: "ai_clone_chat_suggest", + summary: "Trigger 'Suggest reply' in the open AI Clone live chat tab" + ) { _ in + guard AICloneChatAutomation.shared.liveSnapshot != nil else { + return ["error": "no chat sheet open — run ai_clone_open_chat first"] + } + NotificationCenter.default.post(name: .aiCloneChatSuggestRequested, object: nil) + return ["requested": "true", "note": "generation is async; poll ai_clone_chat_state"] + } + + registry.register( + name: "ai_clone_chat_close", + summary: "Close the AI Clone chat sheet" + ) { _ in + NotificationCenter.default.post(name: .aiCloneCloseChatRequested, object: nil) + return nil + } + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneContextService.swift b/desktop/macos/Desktop/Sources/AICloneContextService.swift new file mode 100644 index 00000000000..0d2c403664d --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneContextService.swift @@ -0,0 +1,271 @@ +import Foundation + +/// Live "world knowledge" for AI Clone reply generation: a compact block of the user's +/// saved Memories (real facts about their life) and — when the incoming message looks +/// scheduling-related — their real upcoming calendar, so the clone answers availability +/// questions from actual data instead of hallucinating. +/// +/// Strictly read-only and strictly additive: this service only produces context strings +/// for `AIClonePersonaService.respond()`. It never writes to any calendar, never touches +/// send-mode routing or the pause switch, and a failed/empty fetch yields `nil` so the +/// reply generates exactly as it did without enrichment. +actor AICloneContextService { + static let shared = AICloneContextService() + + // Bounds — the enrichment must stay a short block, not a data dump. + static let maxMemories = 12 + static let maxMemoryChars = 160 + static let maxCalendarEvents = 25 + static let calendarDaysForward = 14 + + // Both fetches are expensive (network call / cookie-decrypting Python subprocess), so + // results — including failures — are cached briefly. Caching nil doubles as a failure + // cooldown: a machine with no Google session doesn't re-spawn Python per message. + private var memoryCache: (block: String?, fetchedAt: Date)? + private var calendarCache: (block: String?, fetchedAt: Date)? + private let memoryTTL: TimeInterval = 15 * 60 + private let calendarTTL: TimeInterval = 10 * 60 + + // MARK: - Memories + + /// Compact background-facts block from the user's saved Memories, or nil when + /// unavailable (fetch failed, signed out, no memories). Never throws. + func memoryContext() async -> String? { + if let cache = memoryCache, Date().timeIntervalSince(cache.fetchedAt) < memoryTTL { + return cache.block + } + var block: String? + if let fixture = Self.loadFixtureStrings(key: "aiCloneMemoriesFixturePath") { + block = Self.renderMemoryBlock(fixture) + } else { + do { + let memories = try await APIClient.shared.getMemories(limit: 60) + // Tips are advice ("you should…"), not facts about the user — skip them. + block = Self.renderMemoryBlock(memories.filter { !$0.isTip }.map(\.content)) + } catch { + log("AICloneContextService: memory fetch failed (replying without): \(error)") + block = nil + } + } + memoryCache = (block, Date()) + return block + } + + /// Pure renderer so bounds/wording are unit-testable. Returns nil for no usable facts. + static func renderMemoryBlock(_ contents: [String]) -> String? { + let facts = contents + .map { $0.replacingOccurrences(of: "\n", with: " ").trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .prefix(maxMemories) + .map { "- \(String($0.prefix(maxMemoryChars)))" } + guard !facts.isEmpty else { return nil } + return """ + BACKGROUND FACTS ABOUT YOU (real, from your saved memories): + \(facts.joined(separator: "\n")) + These are silent background knowledge, NOT things to bring up. Use one ONLY to answer \ + correctly when the other person's message directly asks about it. NEVER introduce a fact \ + from this list on your own, never turn one into a new topic or agenda to "keep the \ + conversation going", never list or show them off, and never mention "memories", "notes", \ + or where you know something from. If the current message doesn't touch any of these, reply \ + as if this list didn't exist. + """ + } + + // MARK: - Calendar + + /// Real-availability block for the next `calendarDaysForward` days, or nil when the + /// calendar can't be read (no browser Google session, fetch error). Never throws. + /// An empty-but-successful fetch still returns a block — "no commitments" is real data. + func calendarContext(now: Date = Date()) async -> String? { + if let cache = calendarCache, now.timeIntervalSince(cache.fetchedAt) < calendarTTL { + return cache.block + } + var block: String? + if let fixture = Self.loadFixtureEvents() { + block = Self.renderCalendarBlock(events: fixture, now: now) + } else { + // Hard deadline on the whole fetch: cookie decryption can stall on keychain + // prompts, and a reply must never wait on that. Timeout/failure → no block. + let events = await Self.withTimeout(seconds: 45) { + try await CalendarReaderService.shared.readEvents( + daysBack: 1, daysForward: Self.calendarDaysForward, maxResults: 150) + } + if let events { + block = Self.renderCalendarBlock(events: events, now: now) + } else { + log("AICloneContextService: calendar fetch failed or timed out (replying without)") + block = nil + } + } + calendarCache = (block, now) + return block + } + + /// Race `work` against a deadline; nil on timeout or error. The loser is cancelled, + /// though a fetch blocked in a subprocess only dies when its own process timeout fires. + private static func withTimeout( + seconds: TimeInterval, _ work: @escaping @Sendable () async throws -> T + ) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { try? await work() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } + + /// Pure renderer: filters to [now, now+14d], sorts ascending, caps line count, and + /// carries the hard no-booking rule. Unit-testable with synthetic events. + static func renderCalendarBlock( + events: [CalendarEvent], now: Date, calendar: Calendar = .current + ) -> String? { + let windowEnd = now.addingTimeInterval(TimeInterval(calendarDaysForward) * 86_400) + + let dayFormatter = DateFormatter() + dayFormatter.calendar = calendar + dayFormatter.timeZone = calendar.timeZone + dayFormatter.dateFormat = "EEE MMM d" + let timeFormatter = DateFormatter() + timeFormatter.calendar = calendar + timeFormatter.timeZone = calendar.timeZone + timeFormatter.dateFormat = "h:mm a" + let nowFormatter = DateFormatter() + nowFormatter.calendar = calendar + nowFormatter.timeZone = calendar.timeZone + nowFormatter.dateFormat = "EEEE, MMM d yyyy, h:mm a" + + let upcoming = events + .compactMap { event -> (start: Date, line: String)? in + guard let start = parseEventDate(event.startTime) else { return nil } + let end = event.endTime.isEmpty ? nil : parseEventDate(event.endTime) + // Keep events still in progress (end in the future) and drop far-future ones. + guard (end ?? start) >= now, start <= windowEnd else { return nil } + let title = event.summary.isEmpty ? "Busy" : event.summary + if event.isAllDay { + return (start, "- \(dayFormatter.string(from: start)) (all day): \(title)") + } + let span = end.map { "–\(timeFormatter.string(from: $0))" } ?? "" + return ( + start, + "- \(dayFormatter.string(from: start)) \(timeFormatter.string(from: start))\(span): \(title)" + ) + } + .sorted { $0.start < $1.start } + + let shown = upcoming.prefix(maxCalendarEvents).map(\.line) + let truncationNote = + upcoming.count > maxCalendarEvents + ? "\n(+\(upcoming.count - maxCalendarEvents) more events not listed — if a specific day isn't covered above, don't promise availability for it)" + : "" + let body = + shown.isEmpty + ? "No commitments in the next \(calendarDaysForward) days — genuinely wide open." + : shown.joined(separator: "\n") + truncationNote + + return """ + YOUR REAL CALENDAR — the ONLY source of truth for your availability. + Right now it is \(nowFormatter.string(from: now)) (local time). + Commitments in the next \(calendarDaysForward) days: + \(body) + When plans or availability come up, answer ONLY from this calendar — never invent \ + or guess events. Say you're free or propose a time only if it doesn't clash with a \ + commitment above. You are ONLY texting: NEVER claim you booked, scheduled, \ + confirmed, or added anything to a calendar, and never offer to "send an invite". + """ + } + + /// Parse a Google Calendar API timestamp: RFC3339 dateTime ("2026-07-06T14:00:00-07:00") + /// or all-day date ("2026-07-06", interpreted in the local timezone). + static func parseEventDate(_ raw: String, calendar: Calendar = .current) -> Date? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + if trimmed.count == 10 { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: trimmed) + } + let iso = ISO8601DateFormatter() + if let date = iso.date(from: trimmed) { return date } + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return iso.date(from: trimmed) + } + + // MARK: - Scheduling detection + + private static let schedulingPhrases: [String] = [ + "free", "available", "availability", "busy", "schedule", "reschedule", "calendar", + "meet", "meeting", "meetup", "hang", "hangout", "lunch", "dinner", "brunch", "coffee", + "drinks", "facetime", "zoom", "plans", "tomorrow", "tonight", "this week", "next week", + "weekend", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "what time", "when are you", "when r u", "when u", "wanna come", "come over", "down to", + "down for", "catch up", "swing by", "stop by", "call you", "call me", "give you a call", + ] + + /// Cheap keyword/time-pattern heuristic: does this message (plus a little recent + /// context) look like it's about availability, plans, or meeting up? False positives + /// just attach harmless extra context; false negatives lose enrichment for one reply. + static func isSchedulingRelated(_ text: String) -> Bool { + let lowered = " " + text.lowercased() + " " + for phrase in schedulingPhrases { + // Poor-man's word boundary: the phrase must not be embedded inside a longer word. + var searchRange = lowered.startIndex.. [String]? { + guard AppBuild.isNonProduction, + let path = UserDefaults.standard.string(forKey: key), !path.isEmpty, + let data = FileManager.default.contents(atPath: path), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String] + else { return nil } + return parsed + } + + /// JSON `[{"summary": …, "start_time": …, "end_time": …, "is_all_day": …}, …]` override. + private static func loadFixtureEvents() -> [CalendarEvent]? { + guard AppBuild.isNonProduction, + let path = UserDefaults.standard.string(forKey: "aiCloneCalendarFixturePath"), + !path.isEmpty, + let data = FileManager.default.contents(atPath: path), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return nil } + return parsed.enumerated().map { index, dict in + CalendarEvent( + id: dict["id"] as? String ?? "fixture-\(index)", + summary: dict["summary"] as? String ?? "Untitled", + startTime: dict["start_time"] as? String ?? "", + endTime: dict["end_time"] as? String ?? "", + attendees: dict["attendees"] as? [String] ?? [], + location: dict["location"] as? String ?? "", + description: dict["description"] as? String ?? "", + isAllDay: dict["is_all_day"] as? Bool ?? false + ) + } + } + + /// Test/harness hook: drop cached blocks so fixture changes take effect immediately. + func invalidateCaches() { + memoryCache = nil + calendarCache = nil + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneHarness.swift b/desktop/macos/Desktop/Sources/AICloneHarness.swift new file mode 100644 index 00000000000..ffdd1551408 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneHarness.swift @@ -0,0 +1,406 @@ +import Foundation + +/// Headless automation actions for the AI Clone pipeline, registered on the local +/// automation bridge (non-production bundles only). They let an agent run the full +/// train → backtest loop against real iMessage history without driving the UI, and +/// dump machine-readable results to disk for inspection. +/// +/// Actions: +/// ai_clone_contacts — list top contacts (rank, handle, count) +/// ai_clone_run rank=1 holdout=12 seed=42 … — start persona+backtest in background +/// ai_clone_respond rank=1 message="…" — one-shot reply via the trained persona +/// +/// `ai_clone_run` returns immediately; progress lines stream to `.progress` and the +/// final JSON report is written to `out` (default /tmp/ai-clone-run.json). +enum AICloneHarness { + + @MainActor private static var runInFlight = false + + @MainActor + static func register(on registry: DesktopAutomationActionRegistry) { + registry.register( + name: "ai_clone_contacts", + summary: "List top iMessage contacts available to the AI Clone pipeline", + params: ["limit"] + ) { params in + let limit = Int(params["limit"] ?? "") ?? 10 + let contacts = try await IMessageReaderService.shared.topContacts(limit: limit) + let listing = contacts.enumerated() + .map { "\($0.offset + 1)\t\($0.element.id)\t\($0.element.messageCount)" } + .joined(separator: "\n") + return ["contacts": listing] + } + + registry.register( + name: "ai_clone_run", + summary: "Run AI Clone train/backtest headlessly; JSON report written to 'out'", + params: ["rank", "holdout", "seed", "iterations", "messages", "out", "reuse_persona", "eval_from"] + ) { params in + guard !runInFlight else { return ["error": "a run is already in flight"] } + let rank = Int(params["rank"] ?? "") ?? 1 + let holdout = Int(params["holdout"] ?? "") ?? 12 + let seed = UInt64(params["seed"] ?? "") ?? 42 + let iterations = Int(params["iterations"] ?? "") ?? 0 + let messageLimit = Int(params["messages"] ?? "") ?? 1500 + let out = params["out"] ?? "/tmp/ai-clone-run.json" + let reusePersona = (params["reuse_persona"] ?? "false") == "true" + let evalFrom = params["eval_from"] + + runInFlight = true + Task.detached(priority: .userInitiated) { + defer { Task { @MainActor in runInFlight = false } } + await Self.executeRun( + rank: rank, holdout: holdout, seed: seed, iterations: iterations, + messageLimit: messageLimit, out: out, reusePersona: reusePersona, evalFrom: evalFrom) + } + return ["started": "true", "out": out, "progress": out + ".progress"] + } + + registry.register( + name: "ai_clone_rejudge", + summary: "Re-score an existing run report's predictions N times each (judge variance)", + params: ["report", "samples", "out"] + ) { params in + guard !runInFlight else { return ["error": "a run is already in flight"] } + guard let report = params["report"] else { return ["error": "missing 'report'"] } + let samples = Int(params["samples"] ?? "") ?? 3 + let out = params["out"] ?? (report + ".rejudged.json") + runInFlight = true + Task.detached(priority: .userInitiated) { + defer { Task { @MainActor in runInFlight = false } } + await Self.executeRejudge(report: report, samples: samples, out: out) + } + return ["started": "true", "out": out] + } + + registry.register( + name: "ai_clone_respond", + summary: "Predict a reply to 'message' using the trained persona for contact 'rank'", + params: ["rank", "message", "live_context"] + ) { params in + let rank = Int(params["rank"] ?? "") ?? 1 + guard let message = params["message"], !message.isEmpty else { + return ["error": "missing 'message'"] + } + // live_context=false disables memory/calendar enrichment (before/after comparisons). + let liveContext = (params["live_context"] ?? "true") != "false" + let contacts = try await IMessageReaderService.shared.topContacts(limit: rank) + guard contacts.count >= rank else { return ["error": "no contact at rank \(rank)"] } + let contact = contacts[rank - 1].asImportedContact() + guard let persona = await AIClonePersonaService.shared.existingPersona(for: contact.id) + else { return ["error": "no persona trained for \(contact.id)"] } + // Harness calls always re-read fixtures/live sources instead of cached blocks. + await AICloneContextService.shared.invalidateCaches() + let reply = try await AIClonePersonaService.shared.respond( + as: persona, to: message, includeLiveContext: liveContext) + return ["reply": reply] + } + + registry.register( + name: "ai_clone_commitments", + summary: "Scan a contact's history for commitments; create Tasks unless create=false", + params: ["rank", "messages", "create", "out"] + ) { params in + let rank = Int(params["rank"] ?? "") ?? 1 + let messageLimit = Int(params["messages"] ?? "") ?? 500 + let create = (params["create"] ?? "true") != "false" + let contacts = try await IMessageReaderService.shared.topContacts(limit: rank) + guard contacts.count >= rank else { return ["error": "no contact at rank \(rank)"] } + let contact = contacts[rank - 1].asImportedContact() + let messages = try await IMessageReaderService.shared.messages( + for: contacts[rank - 1], limit: messageLimit + ).map { $0.asImportedMessage() } + + let commitments: [ExtractedCommitment] + var created = 0 + var duplicates = 0 + if create { + let outcome = try await CommitmentExtractionService.shared.scanAndCreateTasks( + contact: contact, messages: messages) + commitments = outcome.found + created = outcome.created + duplicates = outcome.duplicatesSkipped + } else { + commitments = try await CommitmentExtractionService.shared.scanForCommitments( + contact: contact, messages: messages) + } + + let dayFormatter = ISO8601DateFormatter() + dayFormatter.formatOptions = [.withFullDate] + let foundJSON = commitments.map { commitment -> [String: Any] in + [ + "commitment_text": commitment.commitmentText, + "said_on": commitment.saidOn.map { dayFormatter.string(from: $0) } ?? "", + "context": commitment.context, + "confidence": commitment.confidence, + ] + } + if let out = params["out"] { + try? writeJSON( + [ + "contact": contact.displayName, "platform": contact.platform, + "created": created, "duplicatesSkipped": duplicates, "found": foundJSON, + ], to: out) + } + + let readable = commitments.map { + "• \($0.commitmentText) [\(Int($0.confidence * 100))%]" + }.joined(separator: "\n") + return [ + "contact": contact.displayName, + "count": String(commitments.count), + "created": String(created), + "duplicatesSkipped": String(duplicates), + "commitments": readable, + ] + } + + registry.register( + name: "ai_clone_commitments_reset", + summary: "Delete commitment tasks (staged + action items). only_numbered=true keeps named ones.", + params: ["only_numbered"] + ) { params in + // only_numbered=true deletes just the old phone-number-titled commitment tasks, leaving + // the newer name-titled ones intact. + let onlyNumbered = (params["only_numbered"] ?? "false") == "true" + + func isCommitment(source: String?, category: String?) -> Bool { + source == "commitment" || category == "commitment_tracking" + } + // A run of 10+ consecutive digits, or a "+1"-style handle, means a raw phone number + // leaked into the title (the pre-name-resolver tasks). + func hasRawNumber(_ text: String) -> Bool { + var run = 0 + for ch in text { + if ch.isNumber { run += 1; if run >= 10 { return true } } else { run = 0 } + } + return false + } + + var deletedActions = 0 + var deletedStaged = 0 + var matched: [String] = [] + + if let response = try? await APIClient.shared.getActionItems(limit: 500) { + for item in response.items { + let commit = isCommitment(source: item.source, category: item.category) + || hasRawNumber(item.description) + guard commit else { continue } + if onlyNumbered && !hasRawNumber(item.description) { continue } + if (try? await APIClient.shared.deleteActionItem(id: item.id)) != nil { + deletedActions += 1 + matched.append(item.description) + } + } + } + if let staged = try? await APIClient.shared.getStagedTasks(limit: 500) { + for item in staged.items { + let commit = isCommitment(source: item.source, category: item.category) + || hasRawNumber(item.description) + guard commit else { continue } + if onlyNumbered && !hasRawNumber(item.description) { continue } + if (try? await APIClient.shared.deleteStagedTask(id: item.id)) != nil { + deletedStaged += 1 + matched.append(item.description) + } + } + } + return [ + "deletedActionItems": String(deletedActions), + "deletedStaged": String(deletedStaged), + "deleted": matched.joined(separator: "\n"), + ] + } + } + + // MARK: - Run execution + + private static func executeRun( + rank: Int, holdout: Int, seed: UInt64, iterations: Int, messageLimit: Int, + out: String, reusePersona: Bool, evalFrom: String? + ) async { + let progressPath = out + ".progress" + FileManager.default.createFile(atPath: progressPath, contents: nil) + func progress(_ line: String) { + log("AICloneHarness: \(line)") + appendLine(line, to: progressPath) + } + + // Optional pinned eval set: reuse the exact (them → actual) pairs of a previous + // report so architectures are compared on identical data. + var pinned: [(them: String, me: String)]? = nil + if let evalFrom, + let data = FileManager.default.contents(atPath: evalFrom), + let previous = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let previousPairs = previous["pairs"] as? [[String: Any]] + { + pinned = previousPairs.compactMap { pair in + guard let them = pair["them"] as? String, let actual = pair["actual"] as? String, + !them.isEmpty, !actual.isEmpty + else { return nil } + return (them, actual) + } + progress("pinned eval set: \(pinned?.count ?? 0) pairs from \(evalFrom)") + } + + do { + let contacts = try await IMessageReaderService.shared.topContacts(limit: max(rank, 1)) + guard contacts.count >= rank else { + try writeJSON(["error": "no contact at rank \(rank)"], to: out) + return + } + let contact = contacts[rank - 1].asImportedContact() + let messages = try await IMessageReaderService.shared.messages( + for: contacts[rank - 1], limit: messageLimit + ).map { $0.asImportedMessage() } + progress("contact rank=\(rank) messages=\(messages.count)") + + let started = Date() + // Pinned eval pairs must never appear in a persona's few-shot examples. + let evalKeys = Set( + (pinned ?? []).map { AICloneBacktestService.pairKey(them: $0.them, me: $0.me) }) + var persona: ContactPersona + if reusePersona, let existing = await AIClonePersonaService.shared.existingPersona( + for: contact.id) + { + persona = existing + progress("reusing stored persona (generated \(existing.generatedAt))") + } else { + progress("generating persona…") + persona = try await AIClonePersonaService.shared.generatePersona( + for: contact, messages: messages, excludeExchangeKeys: evalKeys) + progress("persona generated (\(persona.systemPrompt.count) chars)") + } + + var result: BacktestResult + if iterations <= 1 { + progress("backtesting holdout=\(holdout) seed=\(seed) pinned=\(pinned?.count ?? 0)…") + result = try await AICloneBacktestService.shared.runBacktest( + for: contact, messages: messages, persona: persona, + holdoutCount: holdout, seed: seed, pinnedPairs: pinned) + } else { + // Training iterations must never sample (or memorize) the eval pairs. + progress("training loop iterations=\(iterations) holdout=\(holdout) evalExcluded=\(evalKeys.count)…") + let trained = try await AICloneBacktestService.shared.trainToTarget( + for: contact, messages: messages, + maxIterations: iterations, holdoutCount: holdout, + excludePairKeys: evalKeys, + onProgress: { tick in + appendLine( + "iter \(tick.iteration)/\(tick.maxIterations) \(tick.phase)" + + (tick.latestAverage.map { String(format: " best=%.3f", $0) } ?? ""), + to: progressPath) + }) + persona = trained.persona + result = trained.result + // Score the winner on the fixed eval set for cross-run comparability. + progress("final eval (pinned=\(pinned?.count ?? 0), seed=\(seed))…") + result = try await AICloneBacktestService.shared.runBacktest( + for: contact, messages: messages, persona: persona, + holdoutCount: holdout, seed: seed, pinnedPairs: pinned) + } + + let elapsed = Date().timeIntervalSince(started) + progress(String(format: "done avg=%.3f in %.0fs", result.averageScore, elapsed)) + + let report: [String: Any] = [ + "contactId": contact.id, + "rank": rank, + "seed": seed, + "holdout": holdout, + "iterations": iterations, + "messageCount": messages.count, + "averageScore": result.averageScore, + "elapsedSeconds": elapsed, + "systemPrompt": persona.systemPrompt, + "pairs": result.pairs.map { pair -> [String: Any] in + [ + "them": pair.contactMessage, + "actual": pair.actualReply, + "predicted": pair.predictedReply ?? "", + "score": pair.similarityScore ?? -1, + "reasoning": pair.judgeReasoning ?? "", + "context": pair.context.map { "\($0.isFromMe ? "me" : "them"): \($0.text)" }, + ] + }, + ] + try writeJSON(report, to: out) + progress("report written to \(out)") + } catch { + progress("FAILED: \(error.localizedDescription)") + try? writeJSON(["error": error.localizedDescription], to: out) + } + } + + /// Re-judge every pair in a stored report `samples` times and write per-pair mean + /// scores plus the overall mean — same predictions, tighter measurement. + private static func executeRejudge(report: String, samples: Int, out: String) async { + let progressPath = out + ".progress" + FileManager.default.createFile(atPath: progressPath, contents: nil) + do { + guard let data = FileManager.default.contents(atPath: report), + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let pairs = parsed["pairs"] as? [[String: Any]] + else { + try writeJSON(["error": "unreadable report \(report)"], to: out) + return + } + + var rejudged: [[String: Any]] = [] + var means: [Double] = [] + for (index, pair) in pairs.enumerated() { + guard let them = pair["them"] as? String, + let actual = pair["actual"] as? String, + let predicted = pair["predicted"] as? String, !predicted.isEmpty + else { continue } + let context: [ConversationTurn] = (pair["context"] as? [String] ?? []).compactMap { line in + if line.hasPrefix("me: ") { return ConversationTurn(isFromMe: true, text: String(line.dropFirst(4))) } + if line.hasPrefix("them: ") { return ConversationTurn(isFromMe: false, text: String(line.dropFirst(6))) } + return nil + } + var scores: [Double] = [] + for _ in 0.. [ImportedMessage] { + switch contact.platform { + case "telegram": + return await TelegramImportService.shared.messages(for: contact.id, limit: limit) + case "whatsapp": + return await WhatsAppImportService.shared.messages(for: contact.id, limit: limit) + default: + let imContact = IMessageContact( + id: contact.id, displayName: contact.displayName, messageCount: contact.messageCount) + return try await IMessageReaderService.shared.messages(for: imContact, limit: limit) + .map { $0.asImportedMessage() } + } + } +} diff --git a/desktop/macos/Desktop/Sources/AIClonePersonaService.swift b/desktop/macos/Desktop/Sources/AIClonePersonaService.swift new file mode 100644 index 00000000000..8e1217c136d --- /dev/null +++ b/desktop/macos/Desktop/Sources/AIClonePersonaService.swift @@ -0,0 +1,897 @@ +import Foundation + +/// A real verbatim exchange pulled from the message history: what the contact said and +/// how the user actually replied. Used both as few-shot examples inside the system prompt +/// and to exclude training rows from the backtest holdout. +struct ContactExampleExchange: Codable, Sendable, Hashable { + let them: String + let me: String +} + +/// One prior message in a conversation, used as rolling context for `respond()` so the clone +/// predicts a reply in the flow of the chat rather than from a single out-of-context line. +struct ConversationTurn: Sendable { + let isFromMe: Bool + let text: String +} + +/// A generated "persona" for one contact — everything the clone needs to reply as the user: +/// LLM-written voice notes (vocabulary, tone, topics), an algorithmically measured style +/// card (lengths, bursts, casing, punctuation, emoji — computed from the data, not inferred), +/// and real few-shot exchanges. `systemPrompt` is the composed, ready-to-use result. +struct ContactPersona: Codable, Sendable { + let contactId: String + let contactHandle: String + /// The full, ready-to-use system prompt (voice notes + measured style card + hard rules). + let systemPrompt: String + let generatedAt: Date + let messageCountUsed: Int + /// Short human-readable bullets describing recurring patterns the model noticed. + var notablePatterns: [String] = [] + /// Real verbatim (them → me) pairs baked into `systemPrompt` as few-shot examples. + var exampleExchanges: [ContactExampleExchange] = [] + /// The LLM-written portion of the prompt (revised by refinement iterations). + var voiceNotes: String = "" + /// The measured-style block (computed in code; stable across refinements). + var styleCard: String = "" + /// Raw measured features, used for deterministic candidate scoring at respond time. + var styleFeatures: StyleFeatures? = nil +} + +extension ContactPersona { + private enum CodingKeys: String, CodingKey { + case contactId, contactHandle, systemPrompt, generatedAt, messageCountUsed + case notablePatterns, exampleExchanges, voiceNotes, styleCard, styleFeatures + } + + // Lenient decode so personas persisted before newer fields existed still load. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + contactId = try c.decode(String.self, forKey: .contactId) + contactHandle = try c.decode(String.self, forKey: .contactHandle) + systemPrompt = try c.decode(String.self, forKey: .systemPrompt) + generatedAt = try c.decode(Date.self, forKey: .generatedAt) + messageCountUsed = try c.decode(Int.self, forKey: .messageCountUsed) + notablePatterns = try c.decodeIfPresent([String].self, forKey: .notablePatterns) ?? [] + exampleExchanges = + try c.decodeIfPresent([ContactExampleExchange].self, forKey: .exampleExchanges) ?? [] + voiceNotes = try c.decodeIfPresent(String.self, forKey: .voiceNotes) ?? "" + styleCard = try c.decodeIfPresent(String.self, forKey: .styleCard) ?? "" + styleFeatures = try c.decodeIfPresent(StyleFeatures.self, forKey: .styleFeatures) + } +} + +enum AIClonePersonaError: LocalizedError { + case notEnoughMessages + case synthesisFailed(String) + case emptyResponse + + var errorDescription: String? { + switch self { + case .notEnoughMessages: + return "Not enough message history with this contact to build a persona." + case .synthesisFailed(let detail): + return detail.isEmpty ? "Persona generation failed. Please try again." : detail + case .emptyResponse: + return "The model returned an empty persona. Please try again." + } + } +} + +actor AIClonePersonaService { + static let shared = AIClonePersonaService() + + /// Single UserDefaults key holding a `[contactId: ContactPersona]` map, JSON-encoded. + private let storageKey = "aiClonePersonas.v1" + + private var cache: [String: ContactPersona] + + init() { + cache = Self.loadFromDisk(key: "aiClonePersonas.v1") + } + + // MARK: - Public API + + /// Generates (or regenerates) a persona for `contact` from the provided `messages` + /// (any platform, newest-first). Persists on success so re-opening the page keeps it. + /// Also (re)builds the retrieval index used for dynamic few-shot examples. + /// `excludeExchangeKeys` (pair-keys via `AICloneBacktestService.pairKey`) prevents + /// specific exchanges from being baked into the persona's few-shot examples — the + /// backtest harness passes its eval set so measurement stays leak-free. + func generatePersona( + for contact: ImportedContact, messages: [ImportedMessage], + excludeExchangeKeys: Set = [] + ) async throws -> ContactPersona { + guard messages.count >= 4 else { + throw AIClonePersonaError.notEnoughMessages + } + + // Measured style: computed directly from the data, never inferred by the model. + let features = AICloneStyleAnalyzer.extract(from: messages) + let styleCard = AICloneStyleAnalyzer.renderStyleCard(features, contactName: contact.displayName) + + // Retrieval index for dynamic few-shot examples (embedding-backed, lexical fallback). + await AICloneRetrievalService.shared.ensureIndex(contactId: contact.id, messages: messages) + + // Readers return newest-first; render oldest-first so the transcript reads + // chronologically, which is how the model reasons about tone/flow best. + let transcript = Self.formatTranscript(messages.reversed(), contact: contact) + let synthesisPrompt = Self.buildPrompt( + transcript: transcript, contact: contact, messageCount: messages.count) + + let persona = try await makePersona( + fromSynthesisPrompt: synthesisPrompt, contact: contact, messageCount: messages.count, + styleCard: styleCard, styleFeatures: features, excludeExchangeKeys: excludeExchangeKeys) + + store(persona) + return persona + } + + /// Regenerate a persona given the current one plus its worst-scoring backtest pairs, + /// asking the model to revise the voice notes to close those specific gaps. The measured + /// style card is code-derived and stays fixed. Does NOT persist — the training loop + /// decides which iteration's persona to keep. + func refinePersona( + for contact: ImportedContact, + messages: [ImportedMessage], + previous: ContactPersona, + worstPairs: [(contactMessage: String, predicted: String, actual: String, reasoning: String)], + excludeExchangeKeys: Set = [] + ) async throws -> ContactPersona { + guard messages.count >= 4 else { throw AIClonePersonaError.notEnoughMessages } + + let refinePrompt = Self.buildRefinePrompt( + contact: contact, previous: previous, worstPairs: worstPairs) + return try await makePersona( + fromSynthesisPrompt: refinePrompt, contact: contact, messageCount: messages.count, + styleCard: previous.styleCard, styleFeatures: previous.styleFeatures, + excludeExchangeKeys: excludeExchangeKeys) + } + + /// Persist a persona as the active one for its contact. + func store(_ persona: ContactPersona) { + cache[persona.contactId] = persona + persist() + } + + /// Returns a previously generated persona for this contact, if one is cached. + func existingPersona(for contactId: String) -> ContactPersona? { + cache[contactId] + } + + /// All cached personas keyed by contact id (handle). Useful for hydrating the page. + func allPersonas() -> [String: ContactPersona] { + cache + } + + // MARK: - Respond (retrieve → generate candidates → select) + + /// Produce a reply *as the user* to an incoming message from this contact. + /// + /// Pipeline (accuracy-first, multiple LLM calls by design): + /// 1. Retrieve the k most similar real (them → me) exchanges for THIS message and + /// inject the user's actual verbatim replies as situation-specific few-shots. + /// 2. Generate 3 diverse candidate replies in one call (JSON bubbles — no fragile + /// text delimiters; each bubble is one message). + /// 3. Score candidates deterministically against the measured style features and + /// have a critic call pick (and minimally fix) the most indistinguishable one. + /// + /// `excludingPairKeys` removes specific historical instances from retrieval — the + /// backtest passes the held-out pair's own key so the clone can never be handed the + /// answer it is being tested on. + /// + /// `includeLiveContext` (default on) enriches generation with the user's real saved + /// Memories and — for scheduling-shaped messages — their real calendar, via + /// `AICloneContextService`. Read-only and best-effort: a failed fetch just drops the + /// block. The backtest turns it off because it replays historical exchanges, where + /// today's calendar/memories would be anachronistic noise in the eval. + /// + /// Returns bubbles joined with "\n" (same shape as real multi-bubble replies in the + /// history), so UI and judge treat predicted and real replies identically. + func respond( + as persona: ContactPersona, to incomingMessage: String, context: [ConversationTurn] = [], + excludingPairKeys: Set = [], includeLiveContext: Bool = true + ) async throws -> String { + let trimmed = incomingMessage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + // 1. Dynamic few-shot retrieval (best-effort — empty when no index exists). + let retrievalQuery = ([trimmed] + context.suffix(2).map(\.text)).joined(separator: "\n") + let examples = await AICloneRetrievalService.shared.retrieve( + contactId: persona.contactId, incoming: retrievalQuery, k: 10, + excluding: excludingPairKeys) + + // 1b. Live context enrichment: background facts always; real calendar only when the + // message (plus recent turns) looks scheduling-related. Nil blocks silently drop out. + var liveBlocks: [String] = [] + if includeLiveContext { + var hasMemories = false + var hasCalendar = false + if let memoryBlock = await AICloneContextService.shared.memoryContext() { + liveBlocks.append(memoryBlock) + hasMemories = true + } + let scheduling = AICloneContextService.isSchedulingRelated(retrievalQuery) + if scheduling, let calendarBlock = await AICloneContextService.shared.calendarContext() { + liveBlocks.append(calendarBlock) + hasCalendar = true + } + log( + "AIClone respond: live context memories=\(hasMemories) scheduling=\(scheduling) calendar=\(hasCalendar)" + ) + } + + // 2. Generate candidates. + let generationPrompt = Self.buildGenerationPrompt( + incoming: trimmed, context: context, examples: examples) + let generationSystem = ([persona.systemPrompt] + liveBlocks + [Self.candidateFormatInstruction]) + .joined(separator: "\n\n") + + var candidates = try await generateCandidates( + prompt: generationPrompt, system: generationSystem) + candidates = candidates.map(Self.sanitizeBubbles).filter { !$0.isEmpty } + guard !candidates.isEmpty else { throw AIClonePersonaError.emptyResponse } + + // 3. Deterministic style scoring + critic selection. + let features = persona.styleFeatures + let scored = candidates.map { bubbles in + ( + bubbles: bubbles, + style: features.map { AICloneStyleAnalyzer.styleScore(bubbles: bubbles, features: $0) } + ?? 0.5 + ) + } + + var chosen = scored.max { $0.style < $1.style }!.bubbles + if scored.count > 1 { + if let selected = try? await selectCandidate( + scored: scored, incoming: trimmed, context: context, persona: persona) + { + chosen = selected + } + } + + let reply = chosen.joined(separator: "\n") + guard !reply.isEmpty else { throw AIClonePersonaError.emptyResponse } + return reply + } + + /// One LLM call producing up to 3 candidate replies as JSON bubble arrays. Retries once + /// on unparseable output; a lenient string-extraction fallback guarantees raw JSON can + /// never leak into a reply. + private func generateCandidates( + prompt: String, system: String + ) async throws -> [[String]] { + var lastResponse = "" + for attempt in 1...2 { + let responseText = try await runLLM( + prompt: prompt, system: system, model: ModelQoS.Claude.cloneVoice, label: "generate") + // Raw model output is personal-message content — log it for harness debugging on + // dev/test bundles only, never in production. + if AppBuild.isNonProduction { + log("AIClone respond RAW: «\(responseText.replacingOccurrences(of: "\n", with: "⏎").prefix(500))»") + } + lastResponse = responseText + + if let parsed = Self.parseCandidates(from: responseText), !parsed.isEmpty { + return parsed + } + log("AIClonePersonaService: candidate JSON parse failed (attempt \(attempt))") + } + // Lenient fallback: pull the quoted strings of the first candidate array out of the + // malformed JSON. If the text isn't JSON-shaped at all, use its lines as bubbles. + if let extracted = Self.extractFirstCandidateStrings(from: lastResponse), !extracted.isEmpty { + return [extracted] + } + let lines = lastResponse + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.contains("{") && !$0.contains("[") && !$0.contains("\"") } + guard !lines.isEmpty else { throw AIClonePersonaError.emptyResponse } + return [lines] + } + + /// Critic pass: pick the candidate that is most indistinguishable from the real person, + /// with permission to make minimal mechanical fixes (casing/punctuation/emoji) only. + private func selectCandidate( + scored: [(bubbles: [String], style: Double)], + incoming: String, + context: [ConversationTurn], + persona: ContactPersona + ) async throws -> [String] { + let labels = ["A", "B", "C", "D", "E"] + let block = scored.prefix(labels.count).enumerated().map { index, candidate in + let rendered = candidate.bubbles.map { " \($0)" }.joined(separator: "\n") + return "CANDIDATE \(labels[index]) (style-fit \(String(format: "%.2f", candidate.style))):\n\(rendered)" + }.joined(separator: "\n\n") + + let convo = + context.isEmpty + ? "" + : "Conversation so far (oldest first):\n" + + context.map { "\($0.isFromMe ? "Me" : "Them"): \($0.text)" }.joined(separator: "\n") + + "\n\n" + + let system = """ + You are a forensic texting-style analyst. You know exactly how this person texts: + + \(persona.styleCard.isEmpty ? persona.systemPrompt : persona.styleCard) + + Judge ONLY authenticity of voice: would a close friend reading this reply believe this \ + specific person sent it? Output only valid JSON. + """ + + let prompt = """ + \(convo)They just texted: \(incoming) + + Candidate replies the person might send (each line inside a candidate = one separate \ + text message bubble; style-fit is a computed match to their measured style): + + \(block) + + Pick the candidate a close friend would LEAST suspect of being fake AND would actually \ + send in this moment. Polish is a tell of a fake — prefer rough and real over smooth and \ + complete. But a cold, thread-killing one-word reply to a message that clearly wanted a \ + response is ALSO fake for most people: if they're engaged, pick the reply that stays real \ + in this person's voice while keeping the conversation alive. + HARD RULE: REJECT any candidate that introduces a topic, task, plan, or agenda the other \ + person did NOT bring up in this thread — a reply that answers them and then tacks on an \ + unrelated line ("Remove downtime", a random plan, an off-topic question) reads as a \ + malfunction, not a person. Answering cleanly and stopping ALWAYS beats padding with \ + something they didn't ask about. + If their message is a natural closer ("all good?", "cool", "ok", "you good?"), the short \ + clean reply is the winner — do not reward a candidate for manufacturing a topic to keep a \ + finished conversation alive. Only prefer the reply that hands the conversation back when \ + the exchange is genuinely still live AND that reply stays on the topic they raised. You may \ + make tiny mechanical fixes to the winner (casing, punctuation, emoji, trimming an \ + out-of-character word or an off-topic tail bubble) but must NOT rewrite its content or add \ + new ideas. + + Respond ONLY with valid JSON (no markdown): + {"winner": "A", "bubbles": ["final bubble 1", "final bubble 2"]} + """ + + let responseText = try await runLLM( + prompt: prompt, system: system, model: ModelQoS.Claude.cloneVoice, label: "critic") + let jsonText = Self.extractJSONObject(from: responseText) + guard + let data = jsonText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { throw AIClonePersonaError.synthesisFailed("critic parse failed") } + + let winnerLabel = (parsed["winner"] as? String ?? "").trimmingCharacters(in: .whitespaces) + let winnerIndex = labels.firstIndex(of: winnerLabel).map { min($0, scored.count - 1) } + let winner = winnerIndex.map { scored[$0].bubbles } + + let edited = Self.sanitizeBubbles((parsed["bubbles"] as? [String]) ?? []) + // Accept the critic's edited bubbles only if they stay close in size to the winner + // (guards against the critic rewriting instead of lightly fixing). + if let winner, !edited.isEmpty { + let winnerLength = winner.joined(separator: " ").count + let editedLength = edited.joined(separator: " ").count + if editedLength <= max(winnerLength * 3 / 2, winnerLength + 12) { + return edited + } + return winner + } + if let winner { return winner } + if !edited.isEmpty { return edited } + throw AIClonePersonaError.synthesisFailed("critic returned no candidate") + } + + // MARK: - Persona synthesis + + /// Run one synthesis call and turn its JSON into a ready-to-use persona (voice notes + + /// measured style card + few-shot examples + hard in-character rules). + private func makePersona( + fromSynthesisPrompt prompt: String, contact: ImportedContact, messageCount: Int, + styleCard: String, styleFeatures: StyleFeatures?, excludeExchangeKeys: Set = [] + ) async throws -> ContactPersona { + let system = + "You analyze a person's real text-message history with one contact and write notes " + + "that let an AI reply exactly as that person would. Output only valid JSON." + let responseText = try await runLLM( + prompt: prompt, system: system, model: ModelQoS.Claude.cloneVoice, label: "persona") + + let jsonText = Self.extractJSONObject(from: responseText) + guard + let jsonData = jsonText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + else { + throw AIClonePersonaError.synthesisFailed("Couldn't parse the model's response.") + } + + // Accept both the new ("voice_notes") and legacy ("system_prompt") key. + let voiceNotes = ((parsed["voice_notes"] as? String) ?? (parsed["system_prompt"] as? String) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !voiceNotes.isEmpty else { + throw AIClonePersonaError.emptyResponse + } + + let patterns = (parsed["notable_patterns"] as? [String] ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let parsedExchanges: [ContactExampleExchange] = (parsed["example_exchanges"] as? [[String: Any]] ?? []) + .compactMap { dict in + let them = (dict["them"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let me = (dict["me"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !them.isEmpty, !me.isEmpty else { return nil } + return ContactExampleExchange(them: them, me: me) + } + // Cap at 5 (dedup, keep first occurrences) so refine passes can't grow the few-shot + // block unbounded across iterations. Excluded keys (the backtest eval set) can never + // become few-shot examples, or the eval would leak into the prompt. + // Match on the full pair key, and also on the contact-side alone (an exchange that + // quotes an eval pair's trigger leaks even if the copied reply is partial). + let excludedThemPrefixes = excludeExchangeKeys.compactMap { $0.components(separatedBy: "|||").first } + let allowedExchanges = parsedExchanges.filter { exchange in + let key = AICloneBacktestService.pairKey(them: exchange.them, me: exchange.me) + guard !excludeExchangeKeys.contains(key) else { return false } + let themSide = key.components(separatedBy: "|||").first ?? "" + return !excludedThemPrefixes.contains(themSide) + } + let exchanges = Self.dedupeAndCap(allowedExchanges, max: 5) + + let effectivePrompt = Self.composeSystemPrompt( + voiceNotes: voiceNotes, styleCard: styleCard, exchanges: exchanges, contact: contact) + + return ContactPersona( + contactId: contact.id, + contactHandle: contact.id, + systemPrompt: effectivePrompt, + generatedAt: Date(), + messageCountUsed: messageCount, + notablePatterns: patterns, + exampleExchanges: exchanges, + voiceNotes: voiceNotes, + styleCard: styleCard, + styleFeatures: styleFeatures + ) + } + + // MARK: - LLM plumbing (persistent shared bridge, retry on failure) + + /// One long-lived bridge client reused across every clone LLM call. Creating a bridge + /// per call (the old pattern) paid client registration plus a force-refreshed auth + /// token round-trip on EVERY generate/critic/persona call — the single biggest source + /// of reply latency. The persistent client keeps its token fresh on its own timer. + private var sharedBridge: AgentBridge? + /// The bridge serves one query at a time; actor reentrancy across `await` means two + /// concurrent `respond()` calls could otherwise interleave into `requestAlreadyActive`. + private var llmBusy = false + + private func acquireBridge() async throws -> AgentBridge { + if let bridge = sharedBridge, await bridge.isAlive { return bridge } + let bridge = AgentBridge(harnessMode: "piMono") + try await bridge.start() + sharedBridge = bridge + return bridge + } + + private static func isQuotaError(_ error: Error) -> Bool { + if case BridgeError.quotaExceeded = error { return true } + return false + } + + /// Drop (and unregister) the shared bridge so the next call builds a fresh one. + private func discardBridge() { + if let bridge = sharedBridge { + Task { await bridge.stop() } + } + sharedBridge = nil + } + + private func runLLM( + prompt: String, system: String, model: String, label: String + ) async throws -> String { + while llmBusy { try? await Task.sleep(nanoseconds: 50_000_000) } + llmBusy = true + defer { llmBusy = false } + + let maxAttempts = 2 + var lastError: Error? + for attempt in 1...maxAttempts { + do { + let bridge = try await acquireBridge() + let result = try await bridge.query( + prompt: prompt, + systemPrompt: system, + model: model, + onTextDelta: { @Sendable _ in }, + onToolCall: { @Sendable _, _, _ in "" }, + onToolActivity: { @Sendable _, _, _, _ in } + ) + return result.text + } catch { + lastError = error + // A failed query may leave the client wedged — rebuild on the next attempt. + discardBridge() + if attempt < maxAttempts { + log("AIClonePersonaService: \(label) attempt \(attempt) failed, retrying: \(error)") + // A cached quota verdict fails instantly and a fresh bridge re-checks against + // the server — only real (network/runtime) failures earn a backoff pause. + if !Self.isQuotaError(error) { + try? await Task.sleep(nanoseconds: 800_000_000) + } + continue + } + log("AIClonePersonaService: \(label) failed after \(attempt) attempts: \(error)") + } + } + throw AIClonePersonaError.synthesisFailed(lastError?.localizedDescription ?? "") + } + + // MARK: - Prompt + transcript formatting + + private static func formatTranscript( + _ messages: some Sequence, contact: ImportedContact + ) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, HH:mm" + + let themLabel = contact.displayName + return messages.map { message -> String in + let stamp = formatter.string(from: message.date) + let speaker = message.isFromMe ? "Me" : themLabel + let body = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + return "[\(stamp)] \(speaker): \(body)" + }.joined(separator: "\n") + } + + private static func buildPrompt( + transcript: String, contact: ImportedContact, messageCount: Int + ) -> String { + """ + Below is a chronological transcript of \(messageCount) real text messages between the \ + user (labeled "Me") and their contact "\(contact.displayName)" (labeled by name). \ + Study, in forensic detail, how the user — "Me" — writes to this specific contact. + + TRANSCRIPT: + \(transcript) + + Write VOICE NOTES (second person, addressed to the AI that will impersonate the user, \ + e.g. "You are texting with \(contact.displayName)…") capturing everything statistical \ + analysis can NOT: their relationship, running topics and in-jokes, attitude and humor, \ + how the user reacts to different kinds of messages (questions, banter, plans, drama, \ + good/bad news), and their VERBATIM vocabulary. Do not describe message lengths, burst \ + counts, or punctuation frequencies — those are measured separately and injected as data. + + Be concrete and evidence-based: + 1. VERBATIM VOCABULARY: quote the user's actual recurring words/abbreviations with \ + meaning, pulled from real lines — e.g. `says "js" for "just"`, `"ts" for "this"`, \ + `"highk" for "highkey"`. Do NOT write vague descriptions like "uses casual slang". + 2. RELATIONSHIP & TOPICS: what they talk about, the dynamic (who teases whom, shared \ + projects, rivalries), names that come up and who they are. + 3. REACTION PATTERNS: how the user responds to being asked for help, to jokes, to \ + plans, to boring updates — with real examples. + 4. EXAMPLE EXCHANGES: pull 3-5 REAL (them → me) pairs VERBATIM from the transcript \ + (copy exact text; join the user's multi-bubble replies with \\n) that best showcase \ + the reply style. + + Respond ONLY with valid JSON (no markdown, no code fences): + { + "voice_notes": "the full second-person voice notes, rich with verbatim quotes", + "notable_patterns": ["short concrete bullet", "another"], + "example_exchanges": [ + {"them": "exact contact message", "me": "exact user reply (join multi-bubble with \\n)"} + ] + } + + RULES: + - Ground EVERY observation in the actual transcript; never invent traits. + - example_exchanges MUST be copied verbatim from the transcript (real, not fabricated). + - The notes must be specific to THIS contact, not generic. + - Keep notable_patterns to 3-6 concise, concrete bullets. + """ + } + + /// Ask the model to revise the persona's voice notes to fix the specific pairs it got + /// most wrong. The measured style card stays fixed (it is data, not opinion). + private static func buildRefinePrompt( + contact: ImportedContact, + previous: ContactPersona, + worstPairs: [(contactMessage: String, predicted: String, actual: String, reasoning: String)] + ) -> String { + let failures = worstPairs.enumerated().map { index, pair in + let judge = pair.reasoning.isEmpty ? "" : "\n Judge (why it was wrong): \(pair.reasoning)" + return """ + FAILURE \(index + 1): + They said: \(pair.contactMessage) + Your clone replied: \(pair.predicted) + What the user ACTUALLY said: \(pair.actual)\(judge) + """ + }.joined(separator: "\n\n") + + let priorExamples = previous.exampleExchanges + .map { "{\"them\": \"\($0.them)\", \"me\": \"\($0.me)\"}" } + .joined(separator: ",\n ") + + return """ + You previously wrote these voice notes to imitate how the user texts \ + "\(contact.displayName)": + + CURRENT VOICE NOTES: + \(previous.voiceNotes.isEmpty ? previous.systemPrompt : previous.voiceNotes) + + A backtest ran your clone against real history and an impartial judge scored each reply. \ + These are the cases where the clone diverged MOST from what the user actually said: + + \(failures) + + Revise the voice notes to close these gaps. Diagnose what the clone got wrong (wrong \ + attitude? too eager/explanatory? wrong slang? missed how they react to this kind of \ + message?) and rewrite so it would produce replies far closer to the user's real ones \ + above. Keep everything that was already accurate. Message lengths / burst counts / \ + punctuation stats are handled separately — focus on voice, vocabulary, and reactions. + + Respond ONLY with valid JSON (no markdown, no code fences): + { + "voice_notes": "the revised second-person voice notes", + "notable_patterns": ["concrete bullet", "..."], + "example_exchanges": [ + \(priorExamples.isEmpty ? "{\"them\": \"...\", \"me\": \"...\"}" : priorExamples) + ] + } + + RULES: + - Keep or improve the verbatim quotes and example_exchanges; do not fabricate new history. + - Focus your changes on the failure patterns above. + """ + } + + /// Dedupe (by normalized them|me) and keep at most `max` exchanges, preserving order. + private static func dedupeAndCap( + _ exchanges: [ContactExampleExchange], max: Int + ) -> [ContactExampleExchange] { + var seen = Set() + var out: [ContactExampleExchange] = [] + for exchange in exchanges { + let key = + (exchange.them + "|||" + exchange.me) + .lowercased() + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + if seen.insert(key).inserted { + out.append(exchange) + } + if out.count >= max { break } + } + return out + } + + // MARK: - Respond prompts & parsing + + /// Output contract for candidate generation. JSON with explicit bubble arrays — no text + /// delimiters to leak into UI or judge input (each array element = one message bubble). + private static var candidateFormatInstruction: String { + """ + OUTPUT FORMAT (critical): + Respond ONLY with valid JSON, no markdown, no commentary: + {"candidates": [["bubble 1", "bubble 2"], ["bubble 1"], ["bubble 1", "bubble 2", "bubble 3"]]} + - Produce EXACTLY 3 candidate replies. Each candidate is the reply you might really send, \ + as an array of message bubbles (one array element = one separate text message). + - Candidate 1: the pure in-the-moment reaction — however you'd really fire back first \ + (often short). + - Candidate 2: the fragmented burst — 2-4 very short bubbles fired back to back, the way \ + you actually split thoughts mid-stream (not one tidy sentence chopped up). + - Candidate 3: the one that KEEPS IT GOING — react like yourself, then hand the \ + conversation back by engaging with WHAT THEY JUST SAID: a question about their message, a \ + reaction that invites more, riffing on their point. Still fully your voice — never an \ + interview question or a customer-service "let me know if…". + - STAY ON THEIR TOPIC. Every candidate must respond to what THEY actually said. Do NOT \ + introduce a new subject, task, plan, or agenda they didn't bring up — a reply that tacks on \ + an unrelated line reads as a glitch, not a person. "Keep it going" means warmth and \ + engagement on the current thread, never changing the subject. + - SOME MESSAGES ARE CLOSERS. "all good?", "you good?", "cool", "ok", "lol", "kk", "sounds \ + good" are wind-downs — a short clean reply ("yeah all good", "yep") is the correct, complete, \ + human answer. Do NOT manufacture a topic to keep a finished conversation alive; forcing one \ + is what reads as robotic. + - MATCH THEIR ENERGY. If they asked a real question, shared news, or are clearly engaged, \ + ENGAGE BACK on that — a flat one-word reply there kills the thread and reads as cold. If \ + their message is a natural dead-end, a short reply is right. + - Real texting is unpolished: half-thoughts, reactions, your own tangents. Do NOT write \ + smooth, complete, well-reasoned sentences unless the real person does. But dry is not the \ + goal: mirror the person, and when they hand you something to run with, run with it. + - Never mention being an AI. Each bubble is raw message text only. + """ + } + + /// Build the user turn for candidate generation: retrieved real exchanges, the recent + /// conversation, and the incoming message. + private static func buildGenerationPrompt( + incoming: String, context: [ConversationTurn], examples: [RetrievedExchange] + ) -> String { + var sections: [String] = [] + + if !examples.isEmpty { + let block = examples.enumerated().map { index, example -> String in + let reply = example.me.components(separatedBy: "\n") + .map { " \($0)" }.joined(separator: "\n") + return "\(index + 1). Them: \(example.them.replacingOccurrences(of: "\n", with: " / "))\n You really replied:\n\(reply)" + }.joined(separator: "\n") + sections.append( + """ + REAL PAST MOMENTS most similar to right now — these are your ACTUAL verbatim replies \ + to similar messages from them (each indented line = one separate message bubble you sent). \ + Mirror this voice exactly: + \(block) + """) + } + + if !context.isEmpty { + let convo = context.map { "\($0.isFromMe ? "You" : "Them"): \($0.text)" } + .joined(separator: "\n") + sections.append("CURRENT CONVERSATION (oldest first):\n\(convo)") + } + + sections.append( + """ + They just texted: + Them: \(incoming) + + Write the 3 candidate replies (as "You"), staying fully in character. + """) + + return sections.joined(separator: "\n\n") + } + + /// Parse `{"candidates": [["…"], …]}` from a model response. + static func parseCandidates(from text: String) -> [[String]]? { + var jsonText = extractJSONObject(from: text) + // Drop trailing prose after the closing brace (extractJSONObject only trims the front). + if let lastBrace = jsonText.lastIndex(of: "}") { + jsonText = String(jsonText[...lastBrace]) + } + guard + let data = jsonText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + if let arrays = parsed["candidates"] as? [[String]] { + return arrays + } + // Tolerate a single flat candidate: {"candidates": ["a", "b"]} + if let flat = parsed["candidates"] as? [String] { + return [flat] + } + return nil + } + + /// Last-resort extraction from malformed candidate JSON: the quoted strings of the + /// first `[...]` bubble array after "candidates". Never returns JSON syntax as content. + static func extractFirstCandidateStrings(from text: String) -> [String]? { + guard let anchor = text.range(of: "\"candidates\"") else { return nil } + guard let open = text.range(of: "[", range: anchor.upperBound.. [String] { + bubbles + .flatMap { $0.components(separatedBy: "\n") } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0 != "[attachment]" } + .prefix(8) + .map { String($0) } + } + + /// Compose the final system prompt: LLM voice notes + real few-shot exchanges + + /// measured style card + hard "stay in character" rules. + private static func composeSystemPrompt( + voiceNotes: String, styleCard: String, exchanges: [ContactExampleExchange], + contact: ImportedContact + ) -> String { + var parts: [String] = [voiceNotes] + + if !exchanges.isEmpty { + let examples = exchanges.map { "Them: \($0.them)\nYou: \($0.me)" }.joined(separator: "\n\n") + parts.append( + """ + REAL PAST EXCHANGES — mimic this exact style (these actually happened; a line break \ + in your reply = a separate message bubble): + \(examples) + """) + } + + if !styleCard.isEmpty { + parts.append(styleCard) + } + + parts.append( + """ + ABSOLUTE RULES (never break): + - You ARE the user. Never say, hint, or imply that you are an AI, assistant, bot, \ + model, or "chatbot". Never break character, apologize as an assistant, or add \ + disclaimers or meta-commentary. + - Reply ONLY with the raw message text the user would send — no quotation marks around \ + it, no explanations, nothing else. + - Match the exact casing, punctuation (or lack of it), slang, and emoji shown above. \ + Match their message LENGTH and their multi-bubble burst rhythm — if they send several \ + short texts in a row, you must too. + - Match the OTHER person's energy. When they ask something, share news, or are engaged, \ + reply like you actually care and give the thread somewhere to go — a question back, a \ + real reaction, your own take. Don't dead-end a live conversation with a cold one-word \ + reply you'd only send if you were annoyed. When their message is a throwaway, a short \ + reply is right. Never be more helpful, formal, or complete than the real person would — \ + stay rough and real, just don't let the conversation die. + """) + + return parts.joined(separator: "\n\n") + } + + // MARK: - JSON extraction (duplicated from AppleNotesReaderService) + + private static func extractJSONObject(from text: String) -> String { + var responseText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + if responseText.hasPrefix("```") { + if let firstNewline = responseText.firstIndex(of: "\n") { + responseText = String(responseText[responseText.index(after: firstNewline)...]) + } + if responseText.hasSuffix("```") { + responseText = String(responseText.dropLast(3)).trimmingCharacters( + in: .whitespacesAndNewlines) + } + } + + if let braceIndex = responseText.firstIndex(of: "{") { + responseText = String(responseText[braceIndex...]) + } + + return responseText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Persistence (UserDefaults, JSON-encoded) + + private func persist() { + do { + let data = try JSONEncoder().encode(cache) + UserDefaults.standard.set(data, forKey: storageKey) + } catch { + log("AIClonePersonaService: failed to persist personas: \(error)") + } + } + + private static func loadFromDisk(key: String) -> [String: ContactPersona] { + guard let data = UserDefaults.standard.data(forKey: key) else { return [:] } + do { + return try JSONDecoder().decode([String: ContactPersona].self, from: data) + } catch { + log("AIClonePersonaService: failed to decode stored personas: \(error)") + return [:] + } + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneRetrievalService.swift b/desktop/macos/Desktop/Sources/AICloneRetrievalService.swift new file mode 100644 index 00000000000..b17219cf229 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneRetrievalService.swift @@ -0,0 +1,171 @@ +import Foundation + +/// One real historical (contact-said → I-replied) exchange retrieved as a few-shot +/// example for the current incoming message. +struct RetrievedExchange: Sendable { + let them: String + /// My real reply — bubbles joined with "\n" (one line per message bubble). + let me: String + let instanceKey: String + let score: Double +} + +/// Per-contact retrieval index over real (them → me) reply pairs. For each incoming +/// message the clone fetches the k most similar historical situations and shows the +/// model my *actual verbatim replies* to them — dynamic few-shot conditioning, which +/// carries far more signal than a static persona description alone. +/// +/// Similarity is a hybrid of Gemini embedding cosine (semantic) and token-overlap +/// (lexical); if the embedding backend is unavailable the index degrades to pure +/// lexical scoring instead of failing. +actor AICloneRetrievalService { + static let shared = AICloneRetrievalService() + + private struct Entry { + let them: String + let me: String + let instanceKey: String + let tokens: Set + let recency: Double // 0 (oldest) … 1 (newest) + var embedding: [Float]? + } + + private struct Index { + let fingerprint: String + var entries: [Entry] + var hasEmbeddings: Bool + } + + private var indices: [String: Index] = [:] + + /// Stable key for one historical pair instance (text + turn timestamp), so a + /// backtest can exclude exactly the held-out instance while a *different* historical + /// occurrence of the same text stays retrievable (that's legitimate evidence). + static func instanceKey(them: String, me: String, date: Date) -> String { + "\(Int(date.timeIntervalSinceReferenceDate))|\(them.hashValue)|\(me.hashValue)" + } + + /// Build (or reuse) the index for this contact from its message history. + /// Cheap when the fingerprint hasn't changed. + func ensureIndex(contactId: String, messages: [ImportedMessage]) async { + let fingerprint = Self.fingerprint(for: messages) + if let existing = indices[contactId], existing.fingerprint == fingerprint { return } + + let chronological: [ImportedMessage] = + messages.count > 1 && messages.first!.date > messages.last!.date + ? Array(messages.reversed()) : messages + let pairs = AICloneBacktestService.buildPairs(from: chronological) + guard !pairs.isEmpty else { return } + + let dates = pairs.map { $0.turnDate.timeIntervalSinceReferenceDate } + let minDate = dates.min() ?? 0 + let dateSpan = max(1, (dates.max() ?? 1) - minDate) + + var entries = pairs.map { pair in + Entry( + them: pair.contactMessage, + me: pair.actualReply, + instanceKey: Self.instanceKey( + them: pair.contactMessage, me: pair.actualReply, date: pair.turnDate), + tokens: Self.tokenSet(pair.contactMessage), + recency: (pair.turnDate.timeIntervalSinceReferenceDate - minDate) / dateSpan, + embedding: nil) + } + + // Embed the "them" side in batches; on any failure fall back to lexical-only. + var hasEmbeddings = false + do { + var vectors: [[Float]] = [] + var cursor = 0 + while cursor < entries.count { + let chunk = Array(entries[cursor.. Bool { indices[contactId] != nil } + + /// The k most similar historical exchanges for `incoming`, excluding specific + /// held-out instances (leak prevention during backtests) and deduplicating identical + /// replies so the example block shows variety. + func retrieve( + contactId: String, incoming: String, k: Int, excluding excludedKeys: Set = [] + ) async -> [RetrievedExchange] { + guard let index = indices[contactId], !index.entries.isEmpty else { return [] } + + var queryVector: [Float]? = nil + if index.hasEmbeddings { + queryVector = try? await EmbeddingService.shared.embed( + text: String(incoming.prefix(600)), taskType: "RETRIEVAL_QUERY") + } + let queryTokens = Self.tokenSet(incoming) + + var scored: [(entry: Entry, score: Double)] = [] + scored.reserveCapacity(index.entries.count) + for entry in index.entries where !excludedKeys.contains(entry.instanceKey) { + let lexical = Self.overlap(queryTokens, entry.tokens) + var semantic = 0.0 + if let queryVector, let entryVector = entry.embedding { + semantic = Double(Self.cosine(queryVector, entryVector)) + semantic = max(0, min(1, (semantic + 1) / 2)) // map [-1,1] → [0,1] + } + let base = queryVector != nil ? (0.7 * semantic + 0.3 * lexical) : lexical + scored.append((entry, base + 0.04 * entry.recency)) + } + + scored.sort { $0.score > $1.score } + var seenReplies = Set() + var results: [RetrievedExchange] = [] + for (entry, score) in scored { + let replyKey = entry.me.lowercased() + guard seenReplies.insert(replyKey).inserted else { continue } + results.append( + RetrievedExchange(them: entry.them, me: entry.me, instanceKey: entry.instanceKey, score: score)) + if results.count >= k { break } + } + return results + } + + // MARK: - Helpers + + private static func fingerprint(for messages: [ImportedMessage]) -> String { + let first = messages.first.map { "\($0.date.timeIntervalSinceReferenceDate)" } ?? "-" + let last = messages.last.map { "\($0.date.timeIntervalSinceReferenceDate)" } ?? "-" + return "\(messages.count)|\(first)|\(last)" + } + + private static func tokenSet(_ text: String) -> Set { + Set( + text.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { $0.count > 1 }) + } + + /// Symmetric token overlap (cosine of binary vectors): |A∩B| / sqrt(|A||B|). + private static func overlap(_ a: Set, _ b: Set) -> Double { + guard !a.isEmpty, !b.isEmpty else { return 0 } + let shared = a.intersection(b).count + return Double(shared) / (Double(a.count) * Double(b.count)).squareRoot() + } + + private static func cosine(_ a: [Float], _ b: [Float]) -> Float { + guard a.count == b.count, !a.isEmpty else { return 0 } + var dot: Float = 0 + for i in a.indices { dot += a[i] * b[i] } + return dot // embeddings are pre-normalized by EmbeddingService + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneSendModeHarness.swift b/desktop/macos/Desktop/Sources/AICloneSendModeHarness.swift new file mode 100644 index 00000000000..b5b93d23f23 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneSendModeHarness.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Headless automation actions for the AI Clone send-mode system, registered on the local +/// automation bridge (non-production bundles only). They let an agent drive and inspect the +/// send pipeline — per-contact mode, the autonomous kill switch, the pending-draft queue, +/// the sent log, and both the Draft-Review generation path and the real routed send — without +/// clicking through the UI. +/// +/// Actions: +/// ai_clone_sendmode_status — paused flag, mode/pending/sent counts +/// ai_clone_set_paused paused=true|false — flip the global kill switch +/// ai_clone_set_mode contact_id=… mode=… — set a contact's send mode +/// ai_clone_simulate_incoming contact_id=… text=… — feed a fake incoming (needs a persona) +/// ai_clone_pending — dump the pending-draft queue +/// ai_clone_sent — dump the recent-sent log +/// ai_clone_send_routed contact_id=… text=… — send through the real routed send + log +enum AICloneSendModeHarness { + + @MainActor + static func register(on registry: DesktopAutomationActionRegistry) { + let service = AICloneSendModeService.shared + + registry.register( + name: "ai_clone_sendmode_status", + summary: "Report the AI Clone send-mode state (paused flag + counts)" + ) { _ in + [ + "isPaused": service.isPaused ? "true" : "false", + "pendingDrafts": String(service.pendingDrafts.count), + "sentLog": String(service.sentLog.count), + ] + } + + registry.register( + name: "ai_clone_set_paused", + summary: "Set the global autonomous kill switch (true = paused/safe)", + params: ["paused"] + ) { params in + guard let raw = params["paused"] else { return ["error": "missing 'paused'"] } + let paused = (raw as NSString).boolValue + service.setPaused(paused) + return ["isPaused": service.isPaused ? "true" : "false"] + } + + registry.register( + name: "ai_clone_set_mode", + summary: "Set a contact's send mode (manual | draftReview | autonomous)", + params: ["contact_id", "mode"] + ) { params in + guard let contactId = params["contact_id"], !contactId.isEmpty else { + return ["error": "missing 'contact_id'"] + } + guard let raw = params["mode"], let mode = SendMode(rawValue: raw) else { + return ["error": "invalid 'mode' (manual | draftReview | autonomous)"] + } + let accepted = service.setMode(mode, for: contactId) + if !accepted { + return [ + "error": + "blocked: WhatsApp Autonomous requires the one-time unofficial-connection risk acknowledgment", + "contact_id": contactId, + "mode": service.mode(for: contactId).rawValue, + ] + } + return ["contact_id": contactId, "mode": service.mode(for: contactId).rawValue] + } + + registry.register( + name: "ai_clone_simulate_incoming", + summary: + "Simulate an incoming message from a trained contact (drives Draft-Review/Autonomous)", + params: ["contact_id", "text"] + ) { params in + guard let contactId = params["contact_id"], !contactId.isEmpty else { + return ["error": "missing 'contact_id'"] + } + guard let text = params["text"], !text.isEmpty else { return ["error": "missing 'text'"] } + guard let persona = await AIClonePersonaService.shared.allPersonas()[contactId] else { + return ["error": "no trained persona for \(contactId) — train it first"] + } + // Register the contact so the coordinator's router recognizes it, then feed the event. + let contact = ImportedContact( + id: contactId, + displayName: persona.contactHandle, + messageCount: persona.messageCountUsed, + platform: AIClonePlatform.of(contactId: contactId).rawValue) + service.updateActiveContacts([(contact, persona)]) + + let platform = AIClonePlatform.of(contactId: contactId) + let peerKey: String + switch platform { + case .imessage: peerKey = contactId + case .telegram: peerKey = String(contactId.dropFirst("telegram:".count)) + case .whatsapp: peerKey = String(contactId.dropFirst("whatsapp:".count)) + } + service.handleIncoming( + platform: platform, peerKey: peerKey, fromMe: false, text: text, date: Date()) + return [ + "accepted": "true", + "mode": service.mode(for: contactId).rawValue, + "isPaused": service.isPaused ? "true" : "false", + "note": "check ai_clone_pending / ai_clone_sent after a moment (generation is async)", + ] + } + + registry.register( + name: "ai_clone_pending", + summary: "Dump the pending Draft-Review queue" + ) { _ in + let lines = service.pendingDrafts.enumerated().map { i, d in + "\(i). [\(d.contactDisplayName)] incoming=\"\(d.incomingText)\" draft=\"\(d.draftText)\"" + } + return ["count": String(service.pendingDrafts.count), "drafts": lines.joined(separator: "\n")] + } + + registry.register( + name: "ai_clone_sent", + summary: "Dump the recent-sent log" + ) { _ in + let lines = service.recentSent(limit: 20).map { e in + "[\(e.mode.rawValue)] \(e.contactDisplayName): \"\(e.text)\"" + } + return ["count": String(service.sentLog.count), "sent": lines.joined(separator: "\n")] + } + + registry.register( + name: "ai_clone_send_routed", + summary: "Send text through the real routed send (platform dispatch + log); newlines split into separate burst messages", + params: ["contact_id", "text"] + ) { params in + guard let contactId = params["contact_id"], !contactId.isEmpty else { + return ["error": "missing 'contact_id'"] + } + guard let text = params["text"], !text.isEmpty else { return ["error": "missing 'text'"] } + let name = + await AIClonePersonaService.shared.allPersonas()[contactId]?.contactHandle ?? contactId + do { + try await service.sendBubbles( + contactId: contactId, displayName: name, text: text, mode: .manual) + return [ + "sent": "true", "contact_id": contactId, + "bubbles": String(AICloneReplyPresentation.bubbles(from: text).count), + ] + } catch { + return ["error": error.localizedDescription] + } + } + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneSendModeService.swift b/desktop/macos/Desktop/Sources/AICloneSendModeService.swift new file mode 100644 index 00000000000..fb4b6b95aa4 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneSendModeService.swift @@ -0,0 +1,842 @@ +import Foundation + +// MARK: - Send mode + +/// How the AI Clone handles an incoming message from a given contact. +enum SendMode: String, Codable, CaseIterable, Sendable { + /// The clone never acts on its own. You send manually from the Preview Chat, which now + /// dispatches through the real platform send service. This is the default for every contact. + case manual + /// A new incoming message generates a suggested reply that lands in a pending-approval + /// queue. Nothing is sent until you Approve (optionally after editing). + case draftReview + /// A new incoming message is answered and sent automatically — but only while the global + /// kill switch (`isPaused`) is OFF. Guarded hard because it messages real people unattended. + case autonomous + + var label: String { + switch self { + case .manual: return "Manual" + case .draftReview: return "Draft" + case .autonomous: return "Auto" + } + } + + var fullLabel: String { + switch self { + case .manual: return "Manual" + case .draftReview: return "Draft-Review" + case .autonomous: return "Autonomous" + } + } +} + +// MARK: - Platform routing + +/// Which concrete send/listen backend a contact id maps to. iMessage contact ids are the +/// raw handle (unprefixed); Telegram/WhatsApp ids carry a `platform:` prefix. +enum AIClonePlatform: String, Sendable { + case imessage + case telegram + case whatsapp + + /// Whether the clone can actually *send* on this platform. All three platforms route to a + /// real send backend now — WhatsApp goes through the local Baileys sidecar once linked + /// (imported-export contacts are resolved to a number by name or a learned mapping). + var canSend: Bool { true } + + static func of(contactId: String) -> AIClonePlatform { + if contactId.hasPrefix("telegram:") { return .telegram } + if contactId.hasPrefix("whatsapp:") { return .whatsapp } + return .imessage + } +} + +// MARK: - Persisted records + +/// One entry in the "Recent Sent Messages" log. +struct AICloneSentLogEntry: Codable, Identifiable, Sendable { + let id: UUID + let contactId: String + let contactDisplayName: String + let text: String + let mode: SendMode + let timestamp: Date + + init( + id: UUID = UUID(), contactId: String, contactDisplayName: String, text: String, + mode: SendMode, timestamp: Date + ) { + self.id = id + self.contactId = contactId + self.contactDisplayName = contactDisplayName + self.text = text + self.mode = mode + self.timestamp = timestamp + } +} + +/// A reply the clone generated in Draft-Review mode, awaiting the user's decision. +struct AIClonePendingDraft: Identifiable, Sendable, Equatable { + let id: UUID + let contactId: String + let contactDisplayName: String + let incomingText: String + var draftText: String + let createdAt: Date + + init( + id: UUID = UUID(), contactId: String, contactDisplayName: String, incomingText: String, + draftText: String, createdAt: Date + ) { + self.id = id + self.contactId = contactId + self.contactDisplayName = contactDisplayName + self.incomingText = incomingText + self.draftText = draftText + self.createdAt = createdAt + } +} + +// MARK: - Service + +/// The single coordinator that turns trained personas into an actual send experience: +/// per-contact `SendMode`, the global autonomous kill switch, the pending-draft queue, the +/// sent-message log, and the live listeners that drive Draft-Review / Autonomous. +/// +/// MainActor-isolated and `ObservableObject` so the AI Clone page binds directly to it. +/// Persistence is UserDefaults (small, per-user, local — matches the rest of the feature). +@MainActor +final class AICloneSendModeService: ObservableObject { + static let shared = AICloneSendModeService() + + // MARK: Persistence keys + private enum Keys { + static let modes = "aiCloneSendModes" // [contactId: SendMode.rawValue] + static let isPaused = "aiCloneAutonomousPaused" + static let sentLog = "aiCloneSentLog" + // One-time explicit acknowledgment that WhatsApp autonomous sending rides an unofficial + // connection (Baileys / Linked Devices) that carries account-flagging risk. + static let whatsAppAutonomousAcknowledged = "aiCloneWhatsAppAutonomousAcknowledged" + static let whatsAppPhones = "aiCloneWhatsAppPhoneMap" // [contactId: phone digits] + // JSON [KnownContact] — every contact ever registered, so app launch can rebuild + // listener routing without the AI Clone page being opened. + static let knownContacts = "aiCloneKnownContacts" + // Master switch for automatic commitment→Task extraction from incoming messages. + // Absent → ON (the feature is meant to work out of the box once contacts are trained). + static let taskCaptureEnabled = "aiCloneTaskCaptureEnabled" + // Timestamp of the last app-launch "catch up on messages received while closed" sweep, + // so a quick relaunch doesn't re-scan every known contact's history again. + static let lastCommitmentBackstop = "aiCloneLastCommitmentBackstop" + } + + /// How many sent entries we keep. The log is a convenience surface, not an audit store. + private static let maxSentLogEntries = 200 + + // MARK: Published state + + /// Global kill switch for Autonomous sending. Defaults to TRUE (paused) — automated + /// sending to real people must be an explicit, deliberate opt-in. + @Published var isPaused: Bool { + didSet { UserDefaults.standard.set(isPaused, forKey: Keys.isPaused) } + } + + /// Per-contact send mode. Absent → `.manual`. + @Published private(set) var modes: [String: SendMode] + + /// One-time acknowledgment gate for WhatsApp Autonomous mode (see `setMode`). False until + /// the user explicitly confirms the unofficial-connection risk dialog. + @Published private(set) var whatsAppAutonomousAcknowledged: Bool + + /// Learned contactId → phone-number mapping for WhatsApp contacts imported from export + /// files (whose ids aren't addressable). Populated by name-matched incoming messages and + /// sidecar name resolution; consulted on every WhatsApp send. + private(set) var whatsAppPhoneByContactId: [String: String] + + /// Drafts awaiting Approve/Edit/Reject, newest first. + @Published private(set) var pendingDrafts: [AIClonePendingDraft] = [] + + /// Recently sent messages, newest first. + @Published private(set) var sentLog: [AICloneSentLogEntry] + + // MARK: Listener wiring + + /// Contacts + personas the page has registered for live handling. Keyed by contact id. + private var activeContacts: [String: (contact: ImportedContact, persona: ContactPersona)] = [:] + private var isListening = false + /// De-dupes generation for the same incoming message across rapid duplicate poll ticks. + private var handledIncomingKeys: Set = [] + /// Per-contact timestamp of the last commitment/task scan, so a burst of incoming texts + /// triggers at most one history scan per `commitmentScanDebounce` window. + private var lastCommitmentScanAt: [String: Date] = [:] + /// Minimum gap between commitment scans for the same contact. + private let commitmentScanDebounce: TimeInterval = 90 + /// Skip the launch backstop if we already ran one within this window (relaunch guard). + private let commitmentBackstopCooldown: TimeInterval = 6 * 60 * 60 + /// Cap how many contacts the launch backstop scans, most-active first, to avoid an + /// LLM storm the moment the app opens. + private let commitmentBackstopMaxContacts = 8 + + /// Whether automatic commitment→Task extraction from incoming messages is on. Defaults to + /// true; the user can turn it off if it's too noisy. + var isTaskCaptureEnabled: Bool { + UserDefaults.standard.object(forKey: Keys.taskCaptureEnabled) as? Bool ?? true + } + + func setTaskCaptureEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: Keys.taskCaptureEnabled) + } + + private init() { + let defaults = UserDefaults.standard + // Default paused = true. `object(forKey:)` is nil on first launch → paused. + if defaults.object(forKey: Keys.isPaused) == nil { + isPaused = true + } else { + isPaused = defaults.bool(forKey: Keys.isPaused) + } + + if let raw = defaults.dictionary(forKey: Keys.modes) as? [String: String] { + modes = raw.reduce(into: [:]) { acc, kv in + if let mode = SendMode(rawValue: kv.value) { acc[kv.key] = mode } + } + } else { + modes = [:] + } + + if let data = defaults.data(forKey: Keys.sentLog), + let decoded = try? JSONDecoder().decode([AICloneSentLogEntry].self, from: data) + { + sentLog = decoded + } else { + sentLog = [] + } + + whatsAppAutonomousAcknowledged = defaults.bool(forKey: Keys.whatsAppAutonomousAcknowledged) + whatsAppPhoneByContactId = + (defaults.dictionary(forKey: Keys.whatsAppPhones) as? [String: String]) ?? [:] + } + + // MARK: - Mode + + func mode(for contactId: String) -> SendMode { modes[contactId] ?? .manual } + + /// Set a contact's send mode. Returns false (and changes nothing) when the WhatsApp + /// Autonomous acknowledgment gate blocks it — the UI must first show the one-time + /// unofficial-connection risk confirmation and call `acknowledgeWhatsAppAutonomousRisk()`. + @discardableResult + func setMode(_ mode: SendMode, for contactId: String) -> Bool { + guard + !Self.requiresWhatsAppAutonomousAcknowledgment( + mode: mode, contactId: contactId, acknowledged: whatsAppAutonomousAcknowledged) + else { + log("AICloneSendModeService: blocked Autonomous for \(contactId) — WhatsApp risk not acknowledged") + return false + } + if mode == .manual { + modes.removeValue(forKey: contactId) + } else { + modes[contactId] = mode + } + UserDefaults.standard.set( + modes.mapValues(\.rawValue), forKey: Keys.modes) + // A contact that can act on incoming messages needs the listeners running — the + // launch bootstrap skips them when every contact was Manual at the time. + if mode != .manual { startListening() } + return true + } + + /// The WhatsApp-specific extra safety step: Autonomous mode on a WhatsApp contact requires + /// a one-time explicit acknowledgment that the connection method is unofficial (Baileys via + /// Linked Devices) and carries some account-flagging risk. Manual and Draft-Review don't. + /// Pure and side-effect-free so the gate is unit-testable in isolation. + nonisolated static func requiresWhatsAppAutonomousAcknowledgment( + mode: SendMode, contactId: String, acknowledged: Bool + ) -> Bool { + mode == .autonomous && AIClonePlatform.of(contactId: contactId) == .whatsapp && !acknowledged + } + + /// Record the user's explicit acceptance of the WhatsApp-autonomous risk dialog. + func acknowledgeWhatsAppAutonomousRisk() { + whatsAppAutonomousAcknowledged = true + UserDefaults.standard.set(true, forKey: Keys.whatsAppAutonomousAcknowledged) + } + + // MARK: - Pause switch + + func setPaused(_ paused: Bool) { + isPaused = paused + // Unpausing means "go live" — make sure the listeners are actually running. + if !paused, !activeContacts.isEmpty { startListening() } + } + + // MARK: - Active-contact registration (drives listeners) + + /// Point the live handlers at the current set of trained contacts. Called by the page with + /// every contact that has a persona; only Draft-Review / Autonomous contacts actually get + /// acted on, but registering all trained personas keeps routing simple. + func updateActiveContacts(_ entries: [(contact: ImportedContact, persona: ContactPersona)]) { + activeContacts = entries.reduce(into: [:]) { acc, entry in + acc[entry.contact.id] = entry + } + persistKnownContacts(entries.map(\.contact)) + } + + // MARK: - App-launch bootstrap + + /// Lightweight persisted registration (contact metadata only — the persona itself lives + /// in `AIClonePersonaService`) so launch can rebuild routing without the page. + private struct KnownContact: Codable { + let id: String + let displayName: String + let messageCount: Int + let platform: String + } + + /// Rebuild active-contact routing from persisted personas at app launch and start the + /// platform listeners when any contact is in Draft-Review/Autonomous — so the clone + /// works from launch, not only while the AI Clone page happens to be open. + func bootstrapAtLaunch() { + Task { @MainActor in + guard activeContacts.isEmpty else { return } + let personas = await AIClonePersonaService.shared.allPersonas() + guard !personas.isEmpty else { + log("AICloneSendModeService: bootstrap — no trained personas, nothing to do") + return + } + let known = Self.loadKnownContacts() + let entries = personas.map { id, persona in + let saved = known[id] + let contact = ImportedContact( + id: id, + displayName: saved?.displayName ?? persona.contactHandle, + messageCount: saved?.messageCount ?? persona.messageCountUsed, + platform: saved?.platform ?? AIClonePlatform.of(contactId: id).rawValue) + return (contact: contact, persona: persona) + } + updateActiveContacts(entries) + let actionable = entries.filter { mode(for: $0.contact.id) != .manual }.count + // Listeners run when a contact can auto-reply (Draft/Auto) OR when automatic task + // capture is on — task capture needs the live incoming feed for every known contact, + // not just the ones in a send mode. + if actionable > 0 || isTaskCaptureEnabled { + startListening() + } + // Catch up on obligations from messages that landed while the app was closed — the live + // listener only sees rows added after it starts, so without this a request received + // overnight would never become a Task. Bounded + cooldown-guarded so relaunches are cheap. + if isTaskCaptureEnabled { + runLaunchCommitmentBackstop(contacts: entries.map(\.contact)) + } + log( + "AICloneSendModeService: bootstrapped \(entries.count) trained contacts " + + "(\(actionable) in Draft/Auto, task capture \(isTaskCaptureEnabled ? "on" : "off"))") + } + } + + private func persistKnownContacts(_ contacts: [ImportedContact]) { + guard !contacts.isEmpty else { return } + // Merge, never shrink: a partial registration (e.g. the harness registering one + // contact) must not erase names the launch bootstrap depends on. + var known = Self.loadKnownContacts() + for contact in contacts { + known[contact.id] = KnownContact( + id: contact.id, displayName: contact.displayName, + messageCount: contact.messageCount, platform: contact.platform) + } + if let data = try? JSONEncoder().encode(Array(known.values)) { + UserDefaults.standard.set(data, forKey: Keys.knownContacts) + } + } + + /// Contacts the clone knows about (trained personas / registered), for surfaces like the + /// Task-settings blocklist. Prefers the live active set, falling back to the persisted + /// known-contacts store so it works before the AI Clone page has been opened. Sorted by name. + func knownContactsForSettings() -> [(id: String, displayName: String)] { + let source: [(id: String, displayName: String)] + if !activeContacts.isEmpty { + source = activeContacts.values.map { ($0.contact.id, $0.contact.displayName) } + } else { + source = Self.loadKnownContacts().values.map { ($0.id, $0.displayName) } + } + return source.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + private static func loadKnownContacts() -> [String: KnownContact] { + guard let data = UserDefaults.standard.data(forKey: Keys.knownContacts), + let decoded = try? JSONDecoder().decode([KnownContact].self, from: data) + else { return [:] } + return decoded.reduce(into: [:]) { $0[$1.id] = $1 } + } + + // MARK: - Listening lifecycle + + /// Start platform listeners for any platform that has at least one registered contact and + /// is actually available (Telegram logged in; iMessage reachable). Safe to call repeatedly. + /// + /// Note: starting listeners is inert with respect to *sending* — nothing is auto-sent + /// unless a contact is Autonomous AND `isPaused == false`. Draft-Review only enqueues. + func startListening() { + guard !isListening else { return } + isListening = true + + // iMessage: always safe to tail chat.db locally. + Task { + await IMessageSendService.shared.startListening { [weak self] handle, fromMe, text, date in + Task { @MainActor in + self?.handleIncoming( + platform: .imessage, peerKey: handle, fromMe: fromMe, text: text, date: date) + } + } + } + + // Telegram: only if a ready session exists — otherwise skip silently (no failure banner). + Task { + let state = await TelegramSendService.shared.state() + guard case .ready = state else { + log("AICloneSendModeService: Telegram not ready (\(state)) — skipping live listener") + return + } + await TelegramSendService.shared.startListening { [weak self] chatId, fromMe, text, date in + Task { @MainActor in + self?.handleIncoming( + platform: .telegram, peerKey: String(chatId), fromMe: fromMe, text: text, date: date) + } + } + } + + // WhatsApp: only if the sidecar session is already linked — never spawns a link flow. + startWhatsAppListenerIfLinked() + } + + /// Attach the WhatsApp live listener when a linked session exists. Also called by the + /// linking UI right after a successful QR scan so listening starts without a page reload. + func startWhatsAppListenerIfLinked() { + guard isListening else { return } + Task { + // Only resume an existing session; if there's none this is a cheap no-op. + guard WhatsAppSendService.hasSavedSession() else { + log("AICloneSendModeService: WhatsApp not linked — skipping live listener") + return + } + _ = await WhatsAppSendService.shared.startLinking() // spawn + resume saved session + let state = await WhatsAppSendService.shared.state() + guard state.isLinked || state == .connecting else { + log("AICloneSendModeService: WhatsApp session not usable (\(state)) — skipping live listener") + return + } + await WhatsAppSendService.shared.startListening { [weak self] phone, fromMe, text, date, senderName in + Task { @MainActor in + self?.handleIncomingWhatsApp( + phone: phone, fromMe: fromMe, text: text, date: date, senderName: senderName) + } + } + } + } + + func stopListening() { + guard isListening else { return } + isListening = false + Task { await IMessageSendService.shared.stopListening() } + Task { await TelegramSendService.shared.stopListening() } + Task { await WhatsAppSendService.shared.stopListening() } + handledIncomingKeys.removeAll() + } + + // MARK: - Incoming handling + + /// Map a platform + peer key back to the AI Clone contact id. + private func contactId(platform: AIClonePlatform, peerKey: String) -> String { + switch platform { + case .imessage: return peerKey + case .telegram: return "telegram:\(peerKey)" + case .whatsapp: return "whatsapp:\(peerKey)" + } + } + + /// Called for every message the listeners see. We only act on *incoming* messages + /// (`fromMe == false`) for contacts we have a persona for and that aren't in Manual mode. + func handleIncoming( + platform: AIClonePlatform, peerKey: String, fromMe: Bool, text: String, date: Date + ) { + guard !fromMe else { return } + let id = contactId(platform: platform, peerKey: peerKey) + guard let entry = activeContacts[id] else { return } + + // De-dupe: the same incoming line shouldn't spawn two drafts (or two scans) if a poll + // tick repeats. + let key = "\(id)|\(date.timeIntervalSince1970)|\(text.hashValue)" + guard !handledIncomingKeys.contains(key) else { return } + handledIncomingKeys.insert(key) + if handledIncomingKeys.count > 500 { handledIncomingKeys.removeAll() } + + // Task capture is independent of AI-Clone send mode: even a Manual contact's incoming + // requests ("can you send me X", "get groceries") should become Tasks. Debounced so a + // burst of texts triggers one scan. + maybeExtractCommitments(for: entry.contact) + + let mode = mode(for: id) + guard mode != .manual else { return } + + switch Self.action(for: mode, isPaused: isPaused) { + case .ignore: + return + case .draft: + generateDraft(for: entry.contact, persona: entry.persona, incoming: text) + case .autoSend: + autoRespond(for: entry.contact, persona: entry.persona, incoming: text) + } + } + + // MARK: - Automatic commitment → Task capture + + /// Fire-and-forget: scan this contact's recent history for open obligations the user owes + /// and create real Tasks for any new ones. Runs regardless of send mode; debounced per + /// contact so a rapid burst of texts triggers a single scan. All the heavy lifting (LLM + /// call, confidence filter, dedup, staged-task creation + promotion) lives in + /// `CommitmentExtractionService` — this only decides *when* to run it. + func maybeExtractCommitments(for contact: ImportedContact) { + guard isTaskCaptureEnabled else { return } + // Respect the per-person blocklist in Task settings — some contacts should never turn + // their messages into tasks. + if TaskAssistantSettings.shared.isContactBlocked(contact.id) { + log("AICloneSendModeService: task capture skipped for blocked contact \(contact.displayName)") + return + } + let now = Date() + if let last = lastCommitmentScanAt[contact.id], now.timeIntervalSince(last) < commitmentScanDebounce { + return + } + lastCommitmentScanAt[contact.id] = now + Task { + do { + guard + let messages = try? await AICloneMessageLoader.loadMessages(for: contact, limit: 200), + messages.count >= 4 + else { return } + let outcome = try await CommitmentExtractionService.shared.scanAndCreateTasks( + contact: contact, messages: messages) + if outcome.created > 0 { + log( + "AICloneSendModeService: task capture — \(contact.displayName) created \(outcome.created) task(s)") + } + } catch { + log("AICloneSendModeService: task capture failed for \(contact.id): \(error)") + } + } + } + + /// One-shot launch sweep so obligations from messages received while the app was closed are + /// still captured (the live poll only sees rows added after it starts). Cooldown-guarded so + /// frequent relaunches don't re-scan, and capped to the most-active contacts, staggered, to + /// avoid a burst of LLM calls at launch. Per-contact dedup keeps it from re-creating tasks. + private func runLaunchCommitmentBackstop(contacts: [ImportedContact]) { + let defaults = UserDefaults.standard + let lastRun = defaults.object(forKey: Keys.lastCommitmentBackstop) as? Date + if let lastRun, Date().timeIntervalSince(lastRun) < commitmentBackstopCooldown { + log("AICloneSendModeService: launch task-capture backstop skipped (ran recently)") + return + } + defaults.set(Date(), forKey: Keys.lastCommitmentBackstop) + + let ordered = contacts.sorted { $0.messageCount > $1.messageCount } + .prefix(commitmentBackstopMaxContacts) + guard !ordered.isEmpty else { return } + log("AICloneSendModeService: launch task-capture backstop scanning \(ordered.count) contact(s)") + Task { + for contact in ordered { + maybeExtractCommitments(for: contact) + // Space the scans out — CommitmentExtractionService serializes on one LLM bridge, so + // this just avoids queueing them all instantly. + try? await Task.sleep(nanoseconds: 4_000_000_000) + } + } + } + + /// WhatsApp incoming: live events carry a phone number (and often the sender's push-name), + /// but imported-export contacts are keyed by filename — so resolve to whichever registered + /// contact this message belongs to, learning the phone mapping for future sends. + func handleIncomingWhatsApp( + phone: String, fromMe: Bool, text: String, date: Date, senderName: String? + ) { + guard !fromMe else { return } + let candidates = activeContacts.values + .filter { AIClonePlatform.of(contactId: $0.contact.id) == .whatsapp } + .map { (id: $0.contact.id, displayName: $0.contact.displayName) } + guard + let contactId = Self.resolveWhatsAppContactId( + phone: phone, senderName: senderName, activeWhatsAppContacts: candidates, + phoneMap: whatsAppPhoneByContactId) + else { return } + // Remember the number so Draft-Review/Autonomous replies to this contact can send. + if contactId != "whatsapp:\(phone)" { + recordWhatsAppPhone(phone, for: contactId) + } + handleIncoming( + platform: .whatsapp, peerKey: String(contactId.dropFirst("whatsapp:".count)), + fromMe: fromMe, text: text, date: date) + } + + /// Map a live WhatsApp message (phone + optional push-name) onto a registered contact id. + /// Precedence: exact `whatsapp:` id → learned phone mapping → unique + /// case-insensitive display-name match. Pure for unit testing. + nonisolated static func resolveWhatsAppContactId( + phone: String, + senderName: String?, + activeWhatsAppContacts: [(id: String, displayName: String)], + phoneMap: [String: String] + ) -> String? { + let direct = "whatsapp:\(phone)" + if activeWhatsAppContacts.contains(where: { $0.id == direct }) { return direct } + if let mapped = phoneMap.first(where: { $0.value == phone })?.key, + activeWhatsAppContacts.contains(where: { $0.id == mapped }) + { + return mapped + } + if let senderName { + let needle = senderName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return nil } + let matches = activeWhatsAppContacts.filter { + $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == needle + } + if matches.count == 1 { return matches[0].id } + } + return nil + } + + func recordWhatsAppPhone(_ phone: String, for contactId: String) { + guard whatsAppPhoneByContactId[contactId] != phone else { return } + whatsAppPhoneByContactId[contactId] = phone + UserDefaults.standard.set(whatsAppPhoneByContactId, forKey: Keys.whatsAppPhones) + } + + /// What an incoming message should trigger, given the contact's mode and the global kill + /// switch. Pure and side-effect-free so the safety-critical rule — Autonomous NEVER sends + /// while paused — is unit-testable in isolation. + enum IncomingAction: Equatable { case ignore, draft, autoSend } + + nonisolated static func action(for mode: SendMode, isPaused: Bool) -> IncomingAction { + switch mode { + case .manual: return .ignore + case .draftReview: return .draft + case .autonomous: return isPaused ? .draft : .autoSend + } + } + + /// Rolling conversation context for a reply. Loading recent history here also rebuilds + /// the contact's retrieval index when it's cold (personas persist across launches but + /// indices are in-memory), so app-level replies keep their dynamic few-shot examples. + /// Returns the last turns oldest-first, dropping the trailing turn when it *is* the + /// incoming message (live listeners see messages after they land in the local store). + private func replyContext(for contact: ImportedContact, incoming: String) async + -> [ConversationTurn] + { + guard + let messages = try? await AICloneMessageLoader.loadMessages(for: contact, limit: 500), + !messages.isEmpty + else { return [] } + await AICloneRetrievalService.shared.ensureIndex(contactId: contact.id, messages: messages) + // Readers return newest-first; take the newest turns and flip to chronological. + var turns = messages.prefix(12).reversed().map { + ConversationTurn(isFromMe: $0.isFromMe, text: $0.text) + } + if let last = turns.last, !last.isFromMe, + last.text.trimmingCharacters(in: .whitespacesAndNewlines) + == incoming.trimmingCharacters(in: .whitespacesAndNewlines) + { + turns.removeLast() + } + return turns + } + + /// Draft-Review: generate a reply and enqueue it for approval. Never sends. + private func generateDraft(for contact: ImportedContact, persona: ContactPersona, incoming: String) + { + Task { + do { + let context = await replyContext(for: contact, incoming: incoming) + let reply = try await AIClonePersonaService.shared.respond( + as: persona, to: incoming, context: context) + let text = reply.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + pendingDrafts.insert( + AIClonePendingDraft( + contactId: contact.id, contactDisplayName: contact.displayName, + incomingText: incoming, draftText: text, createdAt: Date()), + at: 0) + } catch { + log("AICloneSendModeService: draft generation failed for \(contact.id): \(error)") + } + } + } + + /// Autonomous: generate and send automatically — HARD-GATED on `!isPaused`. If paused (the + /// default), this degrades to enqueuing a draft so nothing is ever sent unattended. + /// + /// Replies dispatch the moment generation finishes — no simulated reading/typing pacing. + private func autoRespond(for contact: ImportedContact, persona: ContactPersona, incoming: String) { + guard !isPaused else { + log("AICloneSendModeService: autonomous paused — enqueuing draft for \(contact.id)") + generateDraft(for: contact, persona: persona, incoming: incoming) + return + } + Task { + do { + await markIncomingRead(contactId: contact.id, displayName: contact.displayName) + let context = await replyContext(for: contact, incoming: incoming) + let reply = try await AIClonePersonaService.shared.respond( + as: persona, to: incoming, context: context) + let text = reply.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + // Re-check the switch right before dispatch — the user may have paused mid-generation. + guard !isPaused else { + generateDraft(for: contact, persona: persona, incoming: incoming) + return + } + try await sendBubbles( + contactId: contact.id, displayName: contact.displayName, text: text, mode: .autonomous) + } catch { + log("AICloneSendModeService: autonomous send failed for \(contact.id): \(error)") + } + } + } + + // MARK: - Pending-draft actions + + /// Approve a pending draft (optionally with an edited body), sending it for real. + func approveDraft(_ draft: AIClonePendingDraft, editedText: String? = nil) { + let text = (editedText ?? draft.draftText).trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + removeDraft(draft.id) + Task { + do { + try await sendBubbles( + contactId: draft.contactId, displayName: draft.contactDisplayName, text: text, + mode: .draftReview) + } catch { + log("AICloneSendModeService: approved-draft send failed for \(draft.contactId): \(error)") + } + } + } + + func rejectDraft(_ draft: AIClonePendingDraft) { removeDraft(draft.id) } + + func updateDraftText(_ id: UUID, to text: String) { + guard let idx = pendingDrafts.firstIndex(where: { $0.id == id }) else { return } + pendingDrafts[idx].draftText = text + } + + private func removeDraft(_ id: UUID) { + pendingDrafts.removeAll { $0.id == id } + } + + // MARK: - Sending (unified) + + /// Send a (possibly multi-bubble) clone reply as separate messages, one per bubble, the + /// way a real person sends a burst — `respond()` joins bubbles with newlines, and sending + /// that as one message would land as a single wall of text. Bubbles go out back-to-back + /// (sends are sequential, so order is preserved) — no simulated typing pacing. Throws on + /// the first failed bubble (earlier bubbles stay sent). + func sendBubbles( + contactId: String, displayName: String, text: String, mode: SendMode + ) async throws { + let bubbles = AICloneReplyPresentation.bubbles(from: text) + guard !bubbles.isEmpty else { throw IMessageSendError.emptyText } + for bubble in bubbles { + try await send(contactId: contactId, displayName: displayName, text: bubble, mode: mode) + } + } + + /// Leave a read receipt where the platform exposes one (WhatsApp "blue ticks"). iMessage + /// and Telegram have no usable read API here. + private func markIncomingRead(contactId: String, displayName: String) async { + guard AIClonePlatform.of(contactId: contactId) == .whatsapp else { return } + guard let target = try? await whatsAppSendTarget(contactId: contactId, displayName: displayName) + else { return } + await WhatsAppSendService.shared.markRead(to: target) + } + + /// Route a send to the correct platform backend and, on success, log it. Throws on failure + /// so callers (manual UI) can surface it; autonomous/draft callers log and swallow. + func send(contactId: String, displayName: String, text: String, mode: SendMode) async throws { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw IMessageSendError.emptyText } + + let platform = AIClonePlatform.of(contactId: contactId) + switch platform { + case .imessage: + try await IMessageSendService.shared.send(toHandle: contactId, text: trimmed) + case .telegram: + let raw = String(contactId.dropFirst("telegram:".count)) + guard let chatId = Int64(raw) else { + throw IMessageSendError.sendScriptFailed("invalid Telegram chat id in \(contactId)") + } + try await TelegramSendService.shared.sendMessage(chatId: chatId, text: trimmed) + case .whatsapp: + let target = try await whatsAppSendTarget(contactId: contactId, displayName: displayName) + try await WhatsAppSendService.shared.send(to: target, text: trimmed) + } + + recordSent( + AICloneSentLogEntry( + contactId: contactId, contactDisplayName: displayName, text: trimmed, mode: mode, + timestamp: Date())) + } + + /// Resolve a WhatsApp contact id to something the sidecar can address. Contact ids are + /// either `whatsapp:` (live contacts — addressable as-is) or + /// `whatsapp:` (imported training chats — resolved via the learned phone + /// mapping, then the linked account's contact list by display name). + private func whatsAppSendTarget(contactId: String, displayName: String) async throws -> String { + let raw = String(contactId.dropFirst("whatsapp:".count)) + if let direct = Self.whatsAppDirectTarget(rawId: raw) { return direct } + if let learned = whatsAppPhoneByContactId[contactId] { return learned } + if let resolved = await WhatsAppSendService.shared.resolvePhone(forName: displayName) { + recordWhatsAppPhone(resolved, for: contactId) + return resolved + } + throw WhatsAppSendError.contactNotResolvable(displayName) + } + + /// A raw WhatsApp contact-id suffix that is directly addressable: a full JID, or a + /// phone-number-shaped string (digits with optional +, spaces, dashes, parens). Returns + /// the normalized target, or nil for non-addressable ids (imported export filenames). + /// Pure for unit testing. + nonisolated static func whatsAppDirectTarget(rawId: String) -> String? { + let raw = rawId.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.hasSuffix("@s.whatsapp.net") { return raw } + let phoneLike = !raw.isEmpty && raw.allSatisfy { $0.isNumber || "+-() .".contains($0) } + guard phoneLike else { return nil } + let digits = raw.filter(\.isNumber) + return digits.count >= 5 ? digits : nil + } + + // MARK: - Sent log + + private func recordSent(_ entry: AICloneSentLogEntry) { + sentLog.insert(entry, at: 0) + if sentLog.count > Self.maxSentLogEntries { + sentLog = Array(sentLog.prefix(Self.maxSentLogEntries)) + } + persistSentLog() + } + + /// Read method for the sent log (the published `sentLog` is the live source; this mirrors + /// the "with a read method" requirement and returns an immutable snapshot). + func recentSent(limit: Int = 50) -> [AICloneSentLogEntry] { + Array(sentLog.prefix(limit)) + } + + func clearSentLog() { + sentLog = [] + persistSentLog() + } + + private func persistSentLog() { + if let data = try? JSONEncoder().encode(sentLog) { + UserDefaults.standard.set(data, forKey: Keys.sentLog) + } + } +} diff --git a/desktop/macos/Desktop/Sources/AICloneStyleAnalyzer.swift b/desktop/macos/Desktop/Sources/AICloneStyleAnalyzer.swift new file mode 100644 index 00000000000..b3099843621 --- /dev/null +++ b/desktop/macos/Desktop/Sources/AICloneStyleAnalyzer.swift @@ -0,0 +1,278 @@ +import Foundation + +/// Stylometric features of how the user texts one specific contact, computed +/// algorithmically from the real message history (never inferred by an LLM). +/// These drive three things: +/// 1. a "measured style" card injected into the clone's system prompt, +/// 2. deterministic candidate scoring when the clone generates multiple replies, +/// 3. honest constraints (length/burst/punctuation) grounded in real distributions. +struct StyleFeatures: Codable, Sendable { + var sampleCount: Int = 0 + + // Per-bubble length distribution (words). + var wordP25: Int = 1 + var wordP50: Int = 4 + var wordP75: Int = 8 + var wordP90: Int = 12 + + // Burst (consecutive from-me bubbles per reply turn) distribution, index 1...6 (6 = "6+"). + // Values are fractions of all reply turns. + var burstShare: [Double] = [0, 0, 0, 0, 0, 0, 0] + + // Casing / punctuation rates over from-me bubbles (0–1). + var lowercaseStartRate: Double = 0 + var uppercaseStartRate: Double = 0 + var terminalPeriodRate: Double = 0 + var questionMarkRate: Double = 0 + var exclamationRate: Double = 0 + var allCapsRate: Double = 0 + + // Emoji habits. + var emojiBubbleRate: Double = 0 + var topEmoji: [String] = [] + + // Vocabulary fingerprint: distinctive tokens (stopwords removed) with counts. + var characteristicTokens: [(token: String, count: Int)] { + get { zip(tokenNames, tokenCounts).map { ($0, $1) } } + set { + tokenNames = newValue.map(\.token) + tokenCounts = newValue.map(\.count) + } + } + private var tokenNames: [String] = [] + private var tokenCounts: [Int] = [] + + private enum CodingKeys: String, CodingKey { + case sampleCount, wordP25, wordP50, wordP75, wordP90, burstShare + case lowercaseStartRate, uppercaseStartRate, terminalPeriodRate + case questionMarkRate, exclamationRate, allCapsRate + case emojiBubbleRate, topEmoji, tokenNames, tokenCounts + } +} + +enum AICloneStyleAnalyzer { + + /// Words too common in English to characterize anyone's voice. + private static let stopwords: Set = [ + "i", "a", "the", "to", "and", "it", "is", "in", "on", "of", "for", "that", "this", + "was", "are", "be", "we", "he", "she", "they", "you", "me", "my", "at", "so", "do", + "if", "or", "as", "an", "but", "not", "no", "yes", "have", "has", "had", "his", + "her", "with", "what", "when", "how", "who", "why", "where", "can", "will", "just", + "get", "got", "did", "does", "im", "its", "were", "there", "then", "them", "than", + "from", "your", "our", "out", "all", "one", "some", "him", "her", "up", "down", + "about", "would", "could", "should", "s", "t", "m", "re", "ll", "d", "ve", "dont", + "didnt", "cant", "go", "going", "know", "like", "think", "want", "make", "made", + "been", "being", "am", "us", "now", "too", "also", "more", "much", "very", + ] + + private static let emojiPattern = try! NSRegularExpression( + pattern: + "[\\x{1F000}-\\x{1FAFF}\\x{2600}-\\x{27BF}\\x{2190}-\\x{21FF}\\x{2B00}-\\x{2BFF}\\x{FE0F}\\x{2764}]" + ) + + /// Extract features from a contact's full (chronological or not) message history. + static func extract(from messages: [ImportedMessage]) -> StyleFeatures { + let chronological = messages.count > 1 && messages.first!.date > messages.last!.date + ? Array(messages.reversed()) : messages + let mine = chronological.filter { $0.isFromMe && $0.text != "[attachment]" } + var features = StyleFeatures() + features.sampleCount = mine.count + guard mine.count >= 20 else { return features } + + // Length percentiles. + let wordCounts = mine.map { wordCount($0.text) }.sorted() + func pct(_ p: Double) -> Int { wordCounts[min(wordCounts.count - 1, Int(Double(wordCounts.count) * p))] } + features.wordP25 = pct(0.25) + features.wordP50 = pct(0.50) + features.wordP75 = pct(0.75) + features.wordP90 = pct(0.90) + + // Burst distribution over reply runs. + var runs: [Int] = [] + var current = 0 + for message in chronological { + if message.isFromMe { + current += 1 + } else if current > 0 { + runs.append(current) + current = 0 + } + } + if current > 0 { runs.append(current) } + if !runs.isEmpty { + var share = [Double](repeating: 0, count: 7) + for run in runs { share[min(run, 6)] += 1 } + features.burstShare = share.map { $0 / Double(runs.count) } + } + + // Casing / punctuation. + let n = Double(mine.count) + var lowerStart = 0 + var upperStart = 0 + var endPeriod = 0 + var hasQuestion = 0 + var hasExclamation = 0 + var allCaps = 0 + for message in mine { + let text = message.text + if text.first?.isLowercase == true { lowerStart += 1 } + if text.first?.isUppercase == true { upperStart += 1 } + if text.hasSuffix(".") && text.count > 2 { endPeriod += 1 } + if text.contains("?") { hasQuestion += 1 } + if text.contains("!") { hasExclamation += 1 } + if text.count > 2, text == text.uppercased(), + text.rangeOfCharacter(from: .letters) != nil + { + allCaps += 1 + } + } + features.lowercaseStartRate = Double(lowerStart) / n + features.uppercaseStartRate = Double(upperStart) / n + features.terminalPeriodRate = Double(endPeriod) / n + features.questionMarkRate = Double(hasQuestion) / n + features.exclamationRate = Double(hasExclamation) / n + features.allCapsRate = Double(allCaps) / n + + // Emoji. + var emojiCounts: [String: Int] = [:] + var bubblesWithEmoji = 0 + for message in mine { + let found = emojiMatches(in: message.text) + if !found.isEmpty { bubblesWithEmoji += 1 } + for e in found where e != "\u{FE0F}" { emojiCounts[e, default: 0] += 1 } + } + features.emojiBubbleRate = Double(bubblesWithEmoji) / n + features.topEmoji = emojiCounts.sorted { $0.value > $1.value }.prefix(5).map(\.key) + + // Vocabulary fingerprint. + var tokenCounts: [String: Int] = [:] + for message in mine { + for token in tokens(in: message.text) where !stopwords.contains(token) { + tokenCounts[token, default: 0] += 1 + } + } + features.characteristicTokens = tokenCounts + .filter { $0.value >= 4 && $0.key.count <= 12 } + .sorted { $0.value > $1.value } + .prefix(18) + .map { ($0.key, $0.value) } + + return features + } + + // MARK: - Style card (prompt block) + + /// Render the measured features as a compact prompt block. Every number is computed + /// from real data, so the model gets ground truth instead of its own impressions. + static func renderStyleCard(_ f: StyleFeatures, contactName: String) -> String { + guard f.sampleCount >= 20 else { return "" } + let pctf: (Double) -> String = { "\(Int(($0 * 100).rounded()))%" } + + let burst1 = pctf(f.burstShare[1]) + let burst2 = pctf(f.burstShare[2]) + let burst3plus = pctf(f.burstShare[3...].reduce(0, +)) + let multiShare = f.burstShare.count > 1 ? 1 - f.burstShare[1] : 0 + + let emojiLine: String + if f.emojiBubbleRate < 0.01 { + emojiLine = "Emoji: almost never (\(pctf(f.emojiBubbleRate)) of messages). Do not use emoji." + } else { + emojiLine = + "Emoji: in \(pctf(f.emojiBubbleRate)) of messages — rare. When used, it is " + + f.topEmoji.prefix(3).joined(separator: " ") + " (never any other)." + } + + let vocab = f.characteristicTokens.prefix(14).map { "\($0.token)" }.joined(separator: ", ") + + return """ + MEASURED STYLE — computed from \(f.sampleCount) of your real messages to \(contactName). \ + These are hard statistical facts about how you text; every reply must fit them: + - Message length: median \(f.wordP50) words per message (25th pct \(f.wordP25), 75th \(f.wordP75), \ + 90th \(f.wordP90)). Keep messages SHORT — a message longer than \(max(f.wordP90 + 4, 12)) words \ + is out of character. + - Splitting into separate messages: \(burst1) of your replies are a single message, \(burst2) are \ + two messages, \(burst3plus) are three or more. \(multiShare > 0.5 ? "Splitting a reply into several short messages is your NORM." : "You usually answer in one message.") + - First character casing: \(pctf(f.lowercaseStartRate)) of your messages start lowercase, \ + \(pctf(f.uppercaseStartRate)) uppercase (phone auto-caps). Mix accordingly; lowercase dominates. + - Punctuation: only \(pctf(f.terminalPeriodRate)) of messages end with a period — almost never end \ + with "." Questions get a "?" only \(pctf(f.questionMarkRate)) of the time — usually you ask \ + questions with no question mark. "!" appears in \(pctf(f.exclamationRate)) of messages. + - \(emojiLine) + - Words you actually use often here: \(vocab) + """ + } + + // MARK: - Deterministic candidate scoring + + /// Score how well `bubbles` (one candidate reply, one string per message bubble) + /// fits the measured distributions. 0…1, higher = more in-style. This is a shape + /// check (length, casing, punctuation, emoji, burst) — semantic fit is the LLM's job. + static func styleScore(bubbles: [String], features f: StyleFeatures) -> Double { + guard !bubbles.isEmpty, f.sampleCount >= 20 else { return 0.5 } + var score = 1.0 + + // Length: penalize bubbles beyond the 90th percentile (scaled by how far). + for bubble in bubbles { + let words = wordCount(bubble) + if words > f.wordP90 { + score -= min(0.25, Double(words - f.wordP90) * 0.02) + } + } + // Reply-total length sanity: total words shouldn't dwarf a typical turn. + let total = bubbles.reduce(0) { $0 + wordCount($1) } + let typicalTurn = max(f.wordP50 * 3, f.wordP90 + 6) + if total > typicalTurn * 2 { score -= 0.2 } + + // Burst-count likelihood: probability of this burst size, scaled. + let burstIndex = min(bubbles.count, 6) + let burstProbability = f.burstShare.indices.contains(burstIndex) ? f.burstShare[burstIndex] : 0 + if burstProbability < 0.03 { score -= 0.2 } else if burstProbability < 0.10 { score -= 0.08 } + + // Terminal periods. + if f.terminalPeriodRate < 0.10 { + let withPeriod = bubbles.filter { $0.hasSuffix(".") && $0.count > 2 }.count + score -= Double(withPeriod) * 0.12 + } + + // Casing: compare uppercase-start share to measured. + let upperStarts = Double(bubbles.filter { $0.first?.isUppercase == true }.count) + let upperShare = upperStarts / Double(bubbles.count) + if f.lowercaseStartRate > 0.55 && upperShare > 0.67 { score -= 0.12 } + + // Emoji: using emoji when the person basically never does, or foreign emoji. + let usedEmoji = bubbles.flatMap { emojiMatches(in: $0) }.filter { $0 != "\u{FE0F}" } + if !usedEmoji.isEmpty { + if f.emojiBubbleRate < 0.01 { + score -= 0.2 + } else if !usedEmoji.allSatisfy({ f.topEmoji.contains($0) }) { + score -= 0.1 + } + } + + // Small bonus for using this person's characteristic vocabulary (capped). + let vocabulary = Set(f.characteristicTokens.prefix(14).map(\.token)) + let usedVocabulary = Set(bubbles.flatMap { tokens(in: $0) }).intersection(vocabulary) + score += min(0.08, Double(usedVocabulary.count) * 0.03) + + return max(0, min(1, score)) + } + + // MARK: - Helpers + + private static func wordCount(_ text: String) -> Int { + text.split { $0.isWhitespace }.count + } + + private static func tokens(in text: String) -> [String] { + text.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty && Int($0) == nil } + } + + private static func emojiMatches(in text: String) -> [String] { + let range = NSRange(text.startIndex..., in: text) + return emojiPattern.matches(in: text, range: range).compactMap { + Range($0.range, in: text).map { String(text[$0]) } + } + } +} diff --git a/desktop/macos/Desktop/Sources/AppState.swift b/desktop/macos/Desktop/Sources/AppState.swift index 3439f529671..b22e1f7354d 100644 --- a/desktop/macos/Desktop/Sources/AppState.swift +++ b/desktop/macos/Desktop/Sources/AppState.swift @@ -702,6 +702,13 @@ extension Notification.Name { /// Posted by the local desktop automation bridge to expand the transcript drawer. static let desktopAutomationShowConversationTranscriptRequested = Notification.Name( "desktopAutomationShowConversationTranscriptRequested") + /// Posted by the automation bridge to open the AI Clone chat sheet for a trained contact + /// (userInfo: ["contactId": String]). + static let aiCloneOpenChatRequested = Notification.Name("aiCloneOpenChatRequested") + /// Posted by the automation bridge to close the AI Clone chat sheet. + static let aiCloneCloseChatRequested = Notification.Name("aiCloneCloseChatRequested") + /// Posted by the automation bridge to trigger "Suggest reply" in the open live chat tab. + static let aiCloneChatSuggestRequested = Notification.Name("aiCloneChatSuggestRequested") /// Posted by the local desktop automation bridge to open an export connector sheet /// (userInfo: ["destination": rawValue]) — for headless e2e inspection. static let desktopAutomationOpenExportRequested = Notification.Name( diff --git a/desktop/macos/Desktop/Sources/CalendarReaderService.swift b/desktop/macos/Desktop/Sources/CalendarReaderService.swift index d4e92836d67..864ef6c47f2 100644 --- a/desktop/macos/Desktop/Sources/CalendarReaderService.swift +++ b/desktop/macos/Desktop/Sources/CalendarReaderService.swift @@ -714,6 +714,12 @@ actor CalendarReaderService { process.standardError = errPipe do { try process.run() + // The keychain item ACL can pop a user prompt; if nobody answers (screen locked, + // background fetch), `security` never exits — terminate instead of hanging the + // actor forever. Same pattern as the Python fetch timeout below. + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(15)) { + if process.isRunning { process.terminate() } + } process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index 6a5e04cd2e2..c17bc30d777 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -9,6 +9,7 @@ actor AgentBridge { let omiSessionId: String let runId: String let attemptId: String + let adapterId: String? let adapterSessionId: String? let terminalStatus: String let inputTokens: Int @@ -16,6 +17,34 @@ actor AgentBridge { let cacheReadTokens: Int let cacheWriteTokens: Int + init( + text: String, + costUsd: Double, + omiSessionId: String, + runId: String, + attemptId: String, + adapterSessionId: String?, + terminalStatus: String, + inputTokens: Int, + outputTokens: Int, + cacheReadTokens: Int, + cacheWriteTokens: Int, + adapterId: String? = nil + ) { + self.text = text + self.costUsd = costUsd + self.omiSessionId = omiSessionId + self.runId = runId + self.attemptId = attemptId + self.adapterSessionId = adapterSessionId + self.terminalStatus = terminalStatus + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheWriteTokens = cacheWriteTokens + self.adapterId = adapterId + } + @available(*, deprecated, message: "Use omiSessionId or adapterSessionId explicitly") var sessionId: String { adapterSessionId ?? omiSessionId } } @@ -167,6 +196,7 @@ actor AgentBridge { model: String? = nil, resume: String? = nil, imageData: Data? = nil, + allowAdapterAutoSelection: Bool = false, onTextDelta: @escaping TextDeltaHandler, onToolCall: @escaping ToolCallHandler, onToolActivity: @escaping ToolActivityHandler, @@ -183,16 +213,11 @@ actor AgentBridge { } if isPiMonoHarness { - if let cached = lastKnownQuota, !cached.allowed { - QueryTracerContext.current?.mark("quota_check", metadata: ["result": "exceeded_cached"]) - throw BridgeError.quotaExceeded( - plan: cached.plan, - unit: cached.unit, - used: cached.used, - limit: cached.limit, - resetAtUnix: cached.resetAt - ) - } + // Client-side usage limit disabled — the backend (which we own) is the single + // source of truth for quota. We never block a piMono request optimistically on + // the client; the server returns a quota error at request time if it truly wants + // to gate. This keeps every piMono surface (Ask Omi chat, AI Clone) unlimited + // from the client's perspective. Still refresh the cached quota for display. QueryTracerContext.current?.mark("quota_check", metadata: ["mode": "optimistic"]) Task { [weak self] in if let quota = await APIClient.shared.fetchChatUsageQuota() { @@ -222,6 +247,7 @@ actor AgentBridge { model: model, resume: resume, imageData: imageData, + allowAdapterAutoSelection: allowAdapterAutoSelection, onTextDelta: onTextDelta, onToolCall: onToolCall, onToolActivity: onToolActivity, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index 50c66003e51..bb06b462275 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -320,6 +320,7 @@ actor AgentRuntimeProcess { model: String?, resume: String?, imageData: Data?, + allowAdapterAutoSelection: Bool, onTextDelta: @escaping AgentBridge.TextDeltaHandler, onToolCall: @escaping AgentBridge.ToolCallHandler, onToolActivity: @escaping AgentBridge.ToolActivityHandler, @@ -372,8 +373,12 @@ actor AgentRuntimeProcess { "clientId": clientId, "prompt": prompt, "systemPrompt": systemPrompt, - "adapterId": adapterId, ] + if allowAdapterAutoSelection { + log("AgentRuntimeProcess: query allows adapter auto-selection") + } else { + queryDict["adapterId"] = adapterId + } if let sessionKey { queryDict["sessionKey"] = sessionKey queryDict["legacySessionKey"] = sessionKey @@ -560,7 +565,7 @@ actor AgentRuntimeProcess { private func applyLocalAgentEnvironment(to env: inout [String: String]) { // Seed auto-discovered commands for every local adapter so the shared Node - // process can route to Hermes or OpenClaw even when it was launched for a + // process can route to Hermes, OpenClaw, or Codex even when it was launched for a // different adapter. registerClient returns early once isRunning, so the // startup adapter's env would otherwise be the only one the process sees. let home = NSHomeDirectory() @@ -575,17 +580,20 @@ actor AgentRuntimeProcess { "\(home)/.hermes/hermes-agent/venv/bin", "\(home)/.hermes/node/bin", "\(home)/.hermes/hermes-agent", + "\(home)/.openclaw/bin", + "\(home)/.openclaw/node/bin", ] - let adapterSearchDirs = adapterPathDirs + [ + let existingPath = env["PATH"] ?? "/usr/bin:/bin" + let existingPathDirs = existingPath.split(separator: ":").map(String.init) + let adapterSearchDirs = uniqueDirectories(adapterPathDirs + existingPathDirs + [ "\(home)/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", - ] + ]) let trustedPathDirs = [ "/opt/homebrew/bin", "/usr/local/bin", ] - let existingPath = env["PATH"] ?? "/usr/bin:/bin" var pathElements: [String] = [] for path in existingPath.split(separator: ":").map(String.init) + trustedPathDirs + adapterPathDirs { if !pathElements.contains(path) { @@ -605,6 +613,12 @@ actor AgentRuntimeProcess { { env["OMI_OPENCLAW_ADAPTER_COMMAND"] = Self.openClawAdapterCommand(openClawPath: openClaw) } + + if env["OMI_CODEX_ADAPTER_COMMAND"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true, + let codex = firstExecutable(named: "codex", in: adapterSearchDirs) + { + env["OMI_CODEX_ADAPTER_COMMAND"] = Self.shellQuote(codex) + } } static func openClawAdapterCommand(openClawPath: String, fileManager: FileManager = .default) -> String { @@ -630,6 +644,16 @@ actor AgentRuntimeProcess { return nil } + private func uniqueDirectories(_ directories: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for directory in directories where !directory.isEmpty && !seen.contains(directory) { + seen.insert(directory) + result.append(directory) + } + return result + } + private func cleanupFailedStart(process failedProcess: Process, error: Error) async { log("AgentRuntimeProcess: startup failed after launch: \(error)") if failedProcess.isRunning { @@ -904,6 +928,9 @@ actor AgentRuntimeProcess { request.continuation.resume(throwing: failure.map(BridgeError.agentRuntimeFailure) ?? BridgeError.agentError(raw)) return } + if let adapterId = message.payload["adapterId"] as? String { + log("AgentRuntimeProcess: agent result adapter=\(adapterId)") + } request.continuation.resume(returning: queryResult(from: message)) } @@ -952,7 +979,8 @@ actor AgentRuntimeProcess { inputTokens: payload["inputTokens"] as? Int ?? 0, outputTokens: payload["outputTokens"] as? Int ?? 0, cacheReadTokens: payload["cacheReadTokens"] as? Int ?? 0, - cacheWriteTokens: payload["cacheWriteTokens"] as? Int ?? 0 + cacheWriteTokens: payload["cacheWriteTokens"] as? Int ?? 0, + adapterId: payload["adapterId"] as? String ) } diff --git a/desktop/macos/Desktop/Sources/Chat/DesktopCapabilityRegistry.swift b/desktop/macos/Desktop/Sources/Chat/DesktopCapabilityRegistry.swift index 87f950d1175..32308a9e816 100644 --- a/desktop/macos/Desktop/Sources/Chat/DesktopCapabilityRegistry.swift +++ b/desktop/macos/Desktop/Sources/Chat/DesktopCapabilityRegistry.swift @@ -295,7 +295,7 @@ enum DesktopCapabilityRegistry { bullets: [ "Use when the user explicitly asks you to run, start, spawn, or launch a subagent/background agent, or for acting in other apps or multi-step work.", "The only way to start a floating-bar subagent is to call spawn_agent; saying you will start one does not start it.", - "If the user asks to use OpenClaw or Hermes, call spawn_agent with provider set to openclaw or hermes.", + "If the user asks to use OpenClaw, Hermes, or Codex, call spawn_agent with provider set to openclaw, hermes, or codex.", "Use delegate_agent instead for canonical Omi child sessions/runs that need durable delegation tracking." ]), Capability( diff --git a/desktop/macos/Desktop/Sources/ContactNameResolver.swift b/desktop/macos/Desktop/Sources/ContactNameResolver.swift new file mode 100644 index 00000000000..355af192886 --- /dev/null +++ b/desktop/macos/Desktop/Sources/ContactNameResolver.swift @@ -0,0 +1,187 @@ +import Foundation +import GRDB + +/// Resolves iMessage handles (phone numbers / emails) to the real contact names in the +/// user's macOS Contacts, by reading the local AddressBook SQLite stores directly. +/// +/// chat.db stores only handles, not names — names live in the Contacts database. The app is +/// sandboxed, so `CNContactStore` would need a separate Contacts entitlement *and* a fresh +/// permission prompt. But the AI Clone feature already requires Full Disk Access (to read +/// chat.db), and that same access can read the AddressBook stores — so name resolution needs +/// no extra permission. If the stores can't be read (no FDA, no Contacts), lookups return nil +/// and callers fall back to the raw handle. +actor ContactNameResolver { + static let shared = ContactNameResolver() + + /// last-10-digits → name, for phone-number handles. + private var phoneToName: [String: String]? + /// lowercased email → name, for email handles. + private var emailToName: [String: String]? + + private init() {} + + /// Drop the cached maps so the next lookup rebuilds from the current AddressBook (e.g. after + /// the user grants Full Disk Access or edits a contact). + func invalidate() { + phoneToName = nil + emailToName = nil + } + + /// Resolve one handle to a display name, or nil if unknown / unreadable. + func name(for handle: String) async -> String? { + await ensureLoaded() + return lookup(handle) + } + + /// Batch-resolve handles → names. Only handles with a match appear in the result. + func resolveAll(_ handles: [String]) async -> [String: String] { + await ensureLoaded() + var out: [String: String] = [:] + for handle in handles { + if let name = lookup(handle) { out[handle] = name } + } + return out + } + + // MARK: - Lookup + + private func lookup(_ handle: String) -> String? { + let trimmed = handle.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + if trimmed.contains("@") { + return emailToName?[trimmed.lowercased()] + } + if let key = Self.phoneKey(trimmed) { + return phoneToName?[key] + } + return nil + } + + /// Last 10 digits of a phone number — a country-code-tolerant match key. Returns nil if the + /// value has too few digits to be a phone number (e.g. a short code we won't match reliably). + private static func phoneKey(_ raw: String) -> String? { + let digits = raw.filter { $0.isNumber } + guard digits.count >= 10 else { return nil } + return String(digits.suffix(10)) + } + + /// Format a record's name: "First Last", falling back to first-only, nickname, then company. + private static func formatName(first: String?, last: String?, nickname: String?, org: String?) + -> String? + { + let f = (first ?? "").trimmingCharacters(in: .whitespaces) + let l = (last ?? "").trimmingCharacters(in: .whitespaces) + let full = [f, l].filter { !$0.isEmpty }.joined(separator: " ") + if !full.isEmpty { return full } + if let nick = nickname?.trimmingCharacters(in: .whitespaces), !nick.isEmpty { return nick } + if let company = org?.trimmingCharacters(in: .whitespaces), !company.isEmpty { return company } + return nil + } + + // MARK: - Load + + private func ensureLoaded() async { + guard phoneToName == nil || emailToName == nil else { return } + var phones: [String: String] = [:] + var emails: [String: String] = [:] + + for dbURL in Self.addressBookDatabaseURLs() { + do { + var config = Configuration() + config.readonly = true + let queue = try DatabaseQueue(path: dbURL.path, configuration: config) + let (dbPhones, dbEmails) = try await queue.read { db in + (Self.fetchPhones(db), Self.fetchEmails(db)) + } + // First non-empty name for a key wins — don't let a later, sparser store overwrite it. + for (key, name) in dbPhones where phones[key] == nil { phones[key] = name } + for (key, name) in dbEmails where emails[key] == nil { emails[key] = name } + } catch { + // Unreadable store (no FDA, locked, or schema mismatch) — skip it, keep any others. + log("ContactNameResolver: could not read \(dbURL.lastPathComponent): \(error)") + } + } + + phoneToName = phones + emailToName = emails + log( + "ContactNameResolver: loaded \(phones.count) phone + \(emails.count) email contact names") + } + + /// Fetch phone-key → name pairs from one AddressBook store (keeps the first name per key). + private static func fetchPhones(_ db: Database) -> [(String, String)] { + let rows = + (try? Row.fetchAll( + db, + sql: """ + SELECT r.ZFIRSTNAME AS first, r.ZLASTNAME AS last, r.ZNICKNAME AS nick, + r.ZORGANIZATION AS org, p.ZFULLNUMBER AS number + FROM ZABCDPHONENUMBER p + JOIN ZABCDRECORD r ON r.Z_PK = p.ZOWNER + WHERE p.ZFULLNUMBER IS NOT NULL + """)) ?? [] + var out: [(String, String)] = [] + var seen = Set() + for row in rows { + guard let number = row["number"] as? String, let key = phoneKey(number) else { continue } + guard !seen.contains(key) else { continue } + if let name = formatName( + first: row["first"], last: row["last"], nickname: row["nick"], org: row["org"]) + { + seen.insert(key) + out.append((key, name)) + } + } + return out + } + + /// Fetch email → name pairs from one AddressBook store (keeps the first name per address). + private static func fetchEmails(_ db: Database) -> [(String, String)] { + let rows = + (try? Row.fetchAll( + db, + sql: """ + SELECT r.ZFIRSTNAME AS first, r.ZLASTNAME AS last, r.ZNICKNAME AS nick, + r.ZORGANIZATION AS org, e.ZADDRESS AS address + FROM ZABCDEMAILADDRESS e + JOIN ZABCDRECORD r ON r.Z_PK = e.ZOWNER + WHERE e.ZADDRESS IS NOT NULL + """)) ?? [] + var out: [(String, String)] = [] + var seen = Set() + for row in rows { + guard let address = row["address"] as? String else { continue } + let key = address.lowercased().trimmingCharacters(in: .whitespaces) + guard !key.isEmpty, !seen.contains(key) else { continue } + if let name = formatName( + first: row["first"], last: row["last"], nickname: row["nick"], org: row["org"]) + { + seen.insert(key) + out.append((key, name)) + } + } + return out + } + + /// Every `AddressBook-v22.abcddb` store: the top-level local store plus each iCloud/exchange + /// source under `Sources//`. Reading all of them covers contacts from every account. + private static func addressBookDatabaseURLs() -> [URL] { + let base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AddressBook", isDirectory: true) + var urls: [URL] = [] + + let topLevel = base.appendingPathComponent("AddressBook-v22.abcddb", isDirectory: false) + if FileManager.default.fileExists(atPath: topLevel.path) { urls.append(topLevel) } + + let sources = base.appendingPathComponent("Sources", isDirectory: true) + if let entries = try? FileManager.default.contentsOfDirectory( + at: sources, includingPropertiesForKeys: nil) + { + for entry in entries { + let candidate = entry.appendingPathComponent("AddressBook-v22.abcddb", isDirectory: false) + if FileManager.default.fileExists(atPath: candidate.path) { urls.append(candidate) } + } + } + return urls + } +} diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 45731c2c6f8..b30bd1c4387 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -336,6 +336,11 @@ final class DesktopAutomationActionRegistry { guard !didRegisterBuiltins else { return } didRegisterBuiltins = true + AICloneHarness.register(on: self) + TelegramLoginHarness.register(on: self) + AICloneSendModeHarness.register(on: self) + AICloneChatHarness.register(on: self) + register( name: "refresh_all_data", summary: "Refresh conversations, chat, tasks, and memories (same as Cmd+R)" @@ -419,6 +424,111 @@ final class DesktopAutomationActionRegistry { return ["sent": query] } + register( + name: "agent_install_prompt_state", + summary: "Return the current floating-bar missing-agent install prompt, if present" + ) { _ in + FloatingControlBarManager.shared.agentInstallPromptStateForAutomation() + } + + register( + name: "agent_install_prompt_trigger", + summary: "Press the current install prompt's primary action (install or sign-in)" + ) { _ in + FloatingControlBarManager.shared.triggerAgentInstallPromptPrimaryAction() + } + + register( + name: "hermes_connect_state", + summary: "Return Hermes install/auth state and the connect flow phase" + ) { _ in + let availability = LocalAgentProviderDetector.availability(for: .hermes) + let service = HermesConnectService.shared + // Reflect current auth (and, when connected, ensure the free-model default + // is provisioned) before reporting state. + service.refreshConnectionState() + var result: [String: String] = [ + "installed": LocalAgentProviderDetector.executablePath(for: .hermes) == nil ? "false" : "true", + "availability": availability.isAvailable + ? "available" : (availability.needsAuthentication ? "needsAuthentication" : "missing"), + "nousAuthenticated": HermesAuthProbe.isNousAuthenticated() ? "true" : "false", + "phase": service.phase.automationValue, + ] + if case .waitingForApproval(let url, let code) = service.phase { + result["verificationURL"] = url.absoluteString + result["userCode"] = code ?? "" + } + if case .failed(let message) = service.phase { + result["failureMessage"] = message + } + return result + } + + register( + name: "hermes_connect_start", + summary: "Start the Hermes → Nous device-code sign-in (opens browser)" + ) { _ in + HermesConnectService.shared.connect() + return ["phase": HermesConnectService.shared.phase.automationValue] + } + + register( + name: "hermes_connect_cancel", + summary: "Cancel an in-flight Hermes sign-in" + ) { _ in + HermesConnectService.shared.cancel() + return ["phase": HermesConnectService.shared.phase.automationValue] + } + + register( + name: "openclaw_connect_state", + summary: "Return OpenClaw install/onboard state and the connect flow phase" + ) { _ in + let availability = LocalAgentProviderDetector.availability(for: .openclaw) + let service = OpenClawConnectService.shared + service.refreshConnectionState() + return [ + "installed": LocalAgentProviderDetector.executablePath(for: .openclaw) == nil ? "false" : "true", + "availability": availability.isAvailable + ? "available" : (availability.needsAuthentication ? "needsAuthentication" : "missing"), + "onboarded": OpenClawOnboardProbe.isOnboarded() ? "true" : "false", + "phase": service.phase.automationValue, + ] + } + + register( + name: "openclaw_connect_start", + summary: "Run OpenClaw's non-interactive onboarding (Gateway + Claude auth)" + ) { _ in + OpenClawConnectService.shared.connect() + return ["phase": OpenClawConnectService.shared.phase.automationValue] + } + + register( + name: "openclaw_connect_cancel", + summary: "Cancel an in-flight OpenClaw setup (including the manual-key watch)" + ) { _ in + OpenClawConnectService.shared.cancel() + return ["phase": OpenClawConnectService.shared.phase.automationValue] + } + + register( + name: "openclaw_set_claude_probe", + summary: "Test hook: force the Claude Code availability probe for OpenClaw onboarding (value=available|missing|clear)", + params: ["value"] + ) { params in + let service = OpenClawConnectService.shared + switch params["value"] ?? "clear" { + case "available": service.claudeCodeAvailabilityOverrideForTesting = true + case "missing": service.claudeCodeAvailabilityOverrideForTesting = false + default: service.claudeCodeAvailabilityOverrideForTesting = nil + } + return [ + "override": params["value"] ?? "clear", + "phase": service.phase.automationValue, + ] + } + register( name: "seed_subagents", summary: "Seed synthetic floating-bar subagents for deterministic UI benchmarks", diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index ac7651ab20d..ba496a63e0c 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -21,6 +21,7 @@ struct AIResponseView: View { var onSendFollowUp: ((String) -> Void)? var onRate: ((String, Int?) -> Void)? var onShareLink: (() async -> String?)? + var onAgentInstallAction: ((String, AgentInstallPromptAction) -> Void)? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -240,7 +241,7 @@ struct AIResponseView: View { } // Response with hover actions - messageWithHoverActions(message: exchange.aiMessage) + assistantMessageView(message: exchange.aiMessage) .padding(.horizontal, 4) } } @@ -323,7 +324,7 @@ struct AIResponseView: View { } } else { // After streaming completes, show with hover actions - messageWithHoverActions(message: message) + assistantMessageView(message: message) } } .padding(.horizontal, 4) @@ -347,6 +348,25 @@ struct AIResponseView: View { } } + private func assistantMessageView(message: ChatMessage) -> some View { + VStack(alignment: .leading, spacing: 8) { + messageWithHoverActions(message: message) + + if let prompt = state.agentInstallPrompt(for: message.id) { + AgentInstallHelpPromptView( + prompt: prompt, + onInstall: { + onAgentInstallAction?(message.id, prompt.primaryAction) + }, + onOpenDocs: { + onAgentInstallAction?(message.id, .openDocs) + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Voice Follow-Up private var voiceFollowUpView: some View { @@ -482,6 +502,118 @@ struct AIResponseView: View { } } +private struct AgentInstallHelpPromptView: View { + let prompt: AgentInstallPromptState + let onInstall: () -> Void + let onOpenDocs: () -> Void + + private var isConnected: Bool { + if case .connected = prompt.status { return true } + return false + } + + /// The command shown in the monospaced block: the manual model-setup + /// command when that fallback is active, otherwise the install command. + private var displayCommand: String? { + if case .needsManualModelSetup(let command) = prompt.status { return command } + return prompt.plan.installCommand + } + + private var primaryActionIcon: String { + if prompt.status.isBusy { return "hourglass" } + if isConnected { return "checkmark" } + if case .needsManualModelSetup = prompt.status { return "terminal" } + return "link" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + primaryActionControl + + Button(action: onOpenDocs) { + HStack(spacing: 6) { + Image(systemName: "safari") + .scaledFont(size: 12, weight: .semibold) + Text(prompt.plan.docsActionTitle) + .scaledFont(size: 12, weight: .medium) + } + .foregroundColor(.white.opacity(0.88)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .disabled(prompt.status.isBusy) + .help(prompt.plan.documentationURL.absoluteString) + .accessibilityLabel("\(prompt.plan.provider.displayName) setup docs") + .accessibilityIdentifier("agent-install-docs-\(prompt.plan.provider.rawValue)") + } + + Text(prompt.detailText) + .scaledFont(size: 12) + .foregroundColor(.white.opacity(0.78)) + .lineLimit(4) + .textSelection(.enabled) + + if case .waitingForApproval(let userCode?) = prompt.status, !userCode.isEmpty { + Text(userCode) + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .textSelection(.enabled) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.12)) + .cornerRadius(6) + .accessibilityLabel("Sign-in code \(userCode)") + } + + if let command = displayCommand { + Text(command) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.58)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(.top, 1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) + ) + .cornerRadius(8) + } + + private var primaryActionControl: some View { + let isEnabled = prompt.primaryActionEnabled && !isConnected + + return HStack(spacing: 6) { + Image(systemName: primaryActionIcon) + .scaledFont(size: 12, weight: .semibold) + Text(prompt.primaryActionTitle) + .scaledFont(size: 12, weight: .semibold) + } + .foregroundColor(.white.opacity(isEnabled ? 1.0 : 0.6)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.white.opacity(isEnabled ? 0.18 : 0.08)) + .cornerRadius(8) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { + guard isEnabled else { return } + onInstall() + } + .help(prompt.plan.commandDisplay) + .accessibilityElement(children: .combine) + .accessibilityLabel(prompt.primaryActionTitle) + .accessibilityIdentifier("agent-install-\(prompt.plan.provider.rawValue)") + } +} + // MARK: - Message Hover Overlay /// Overlay that shows action buttons (thumbs up/down, copy, info) on hover over an AI message diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift index a44c577778a..a071b745895 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift @@ -172,11 +172,15 @@ final class AgentPillsManager: ObservableObject { enum DirectedProvider: String, Equatable { case hermes case openclaw + case codex + case claudeCode var displayName: String { switch self { case .hermes: return "Hermes" case .openclaw: return "OpenClaw" + case .codex: return "Codex" + case .claudeCode: return "Claude Code" } } @@ -184,6 +188,8 @@ final class AgentPillsManager: ObservableObject { switch self { case .hermes: return .hermes case .openclaw: return .openclaw + case .codex: return .codex + case .claudeCode: return .acp } } @@ -191,6 +197,8 @@ final class AgentPillsManager: ObservableObject { switch self { case .hermes: return "hermes" case .openclaw: return "openclaw" + case .codex: return "codex" + case .claudeCode: return "claude" } } @@ -198,6 +206,8 @@ final class AgentPillsManager: ObservableObject { switch self { case .hermes: return "OMI_HERMES_ADAPTER_COMMAND" case .openclaw: return "OMI_OPENCLAW_ADAPTER_COMMAND" + case .codex: return "OMI_CODEX_ADAPTER_COMMAND" + case .claudeCode: return "" } } @@ -366,18 +376,35 @@ final class AgentPillsManager: ObservableObject { return 1 } - nonisolated static func providerDirective(from text: String) -> ProviderDirective? { - providerDirective(from: text, contextualPreviousRequest: nil) + static func providerDirective(from text: String) async -> ProviderDirective? { + await providerDirective(from: text, contextualPreviousRequest: nil) } - nonisolated static func providerDirective( + static func providerDirective( + from text: String, + contextualPreviousRequest: String? + ) async -> ProviderDirective? { + if let directive = await runProviderDirectiveClassifier( + for: text, + contextualPreviousRequest: contextualPreviousRequest + ) { + return directive + } + return literalProviderDirective(from: text, contextualPreviousRequest: contextualPreviousRequest) + } + + nonisolated static func literalProviderDirective(from text: String) -> ProviderDirective? { + literalProviderDirective(from: text, contextualPreviousRequest: nil) + } + + nonisolated static func literalProviderDirective( from text: String, contextualPreviousRequest: String? ) -> ProviderDirective? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - let providerPattern = "(open\\s*claw|openclaw|hermes)" + let providerPattern = "(open\\s*claw|openclaw|hermes|codex|claude\\s*code|claudecode)" let patterns = [ #"(?i)^\s*(?:please\s+)?(?:(?:i\s+)?meant\s+)?(?:ask|tell|ping|message|run|use|try)\s+\#(providerPattern)\b(?:\s+(.*))?$"#, #"(?i)^\s*(?:please\s+)?\#(providerPattern)\s*[:,\-]\s*(.*)$"#, @@ -395,6 +422,8 @@ final class AgentPillsManager: ObservableObject { switch providerToken { case "openclaw": provider = .openclaw case "hermes": provider = .hermes + case "codex": provider = .codex + case "claudecode": provider = .claudeCode default: continue } @@ -426,6 +455,167 @@ final class AgentPillsManager: ObservableObject { return nil } + private static func runProviderDirectiveClassifier( + for text: String, + contextualPreviousRequest: String? + ) async -> ProviderDirective? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let baseURL = await APIClient.shared.rustBackendURL + guard !baseURL.isEmpty else { + log("AgentPill: provider directive classifier skipped — rustBackendURL empty") + return nil + } + let normalized = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" + guard let url = URL(string: normalized + "v2/chat/completions") else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 3 + do { + let headers = try await APIClient.shared.buildHeaders(requireAuth: true) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } catch { + log("AgentPill: provider directive classifier skipped — auth header unavailable (\(error.localizedDescription))") + return nil + } + + let previous = contextualPreviousRequest? + .trimmingCharacters(in: .whitespacesAndNewlines) + let previousBlock = (previous?.isEmpty == false) + ? "\nPrevious visible user request, for correction context only:\n\"\(previous!)\"\n" + : "" + let prompt = """ + Classify whether the user is explicitly directing this task to one named local agent. + + User message: + "\(trimmed)" + \(previousBlock) + Agents: + - Hermes => "hermes" + - OpenClaw => "openclaw" + - Codex => "codex" + - Claude Code => "claude_code" + + Return ONLY a single-line JSON object: + {"provider":"hermes"|"openclaw"|"codex"|"claude_code"|null,"task":""} + + Use a provider only when the user is asking that named agent to handle, do, answer, inspect, run, draft, send, or work on the current task. Natural phrasing counts: "can you get OpenClaw on this", "have Codex handle it", "I want Hermes to do this one". + Return null for comparisons, definitions, documentation questions, architecture questions, or casual mentions such as "what is OpenClaw?", "compare Hermes and Codex", or "openclaw architecture". + If the user is only correcting the provider ("I meant Hermes", "OpenClaw instead") and the previous request is relevant, put the previous task in "task". + If the provider is explicit but no task remains, use "Say how it's going." + """ + let body: [String: Any] = [ + "model": ModelQoS.Claude.synthesis, + "max_tokens": 120, + "messages": [["role": "user", "content": prompt]], + "stream": false, + ] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + log("AgentPill: provider directive classifier failed — no HTTP response") + return nil + } + guard (200..<300).contains(http.statusCode) else { + let bodyText = String(data: data.prefix(200), encoding: .utf8) ?? "" + log("AgentPill: provider directive classifier HTTP \(http.statusCode) — \(bodyText)") + return nil + } + guard let text = chatCompletionText(from: data) else { + log("AgentPill: provider directive classifier response shape unexpected") + return nil + } + let directive = parseProviderDirectiveClassifierOutput( + text, + originalText: trimmed, + contextualPreviousRequest: contextualPreviousRequest + ) + if let directive { + log("AgentPill: provider directive classifier matched provider=\(directive.provider.rawValue)") + } else { + log("AgentPill: provider directive classifier returned no explicit provider") + } + return directive + } catch { + log("AgentPill: provider directive classifier threw — \(error.localizedDescription)") + return nil + } + } + + private nonisolated static func chatCompletionText(from data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let text = message["content"] as? String else { + return nil + } + return text + } + + nonisolated static func parseProviderDirectiveClassifierOutput( + _ output: String, + originalText: String, + contextualPreviousRequest: String? + ) -> ProviderDirective? { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + let jsonBody: String + if let firstBrace = trimmed.firstIndex(of: "{"), + let lastBrace = trimmed.lastIndex(of: "}"), + firstBrace <= lastBrace { + jsonBody = String(trimmed[firstBrace...lastBrace]) + } else { + jsonBody = trimmed + } + guard let data = jsonBody.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + let providerToken = (payload["provider"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + let provider: DirectedProvider + switch providerToken { + case "hermes": provider = .hermes + case "openclaw": provider = .openclaw + case "codex": provider = .codex + case "claudecode": provider = .claudeCode + default: return nil + } + + var task = (payload["task"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if task.isEmpty, + let contextual = contextualPreviousRequest + .flatMap({ providerObjective(from: $0) })? + .trimmingCharacters(in: .whitespacesAndNewlines), + !contextual.isEmpty { + task = contextual + } + if task.isEmpty { + task = providerObjective(from: originalText).trimmingCharacters(in: .whitespacesAndNewlines) + } + if task.isEmpty { + task = "Say how it's going." + } + + return ProviderDirective( + provider: provider, + rewrittenQuery: task, + title: provider.displayName, + ack: "Asking \(provider.displayName)." + ) + } + nonisolated static func providerObjective(from text: String) -> String { let original = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !original.isEmpty else { return original } @@ -753,7 +943,8 @@ final class AgentPillsManager: ObservableObject { systemPromptStyle: .floating, sessionKey: "agent-\(pill.id.uuidString)", surfaceRef: surfaceRef, - legacyClientScope: AgentLegacyClientScope.floatingPill + legacyClientScope: AgentLegacyClientScope.floatingPill, + allowAdapterAutoSelection: bridgeHarnessOverride == nil ) guard !Task.isCancelled else { return } self.complete(pill: pill, provider: provider, finalText: finalText) @@ -820,7 +1011,8 @@ final class AgentPillsManager: ObservableObject { systemPromptStyle: .floating, sessionKey: "agent-\(pill.id.uuidString)", surfaceRef: surfaceRef, - legacyClientScope: AgentLegacyClientScope.floatingPill) + legacyClientScope: AgentLegacyClientScope.floatingPill, + allowAdapterAutoSelection: pill.bridgeHarnessOverride == nil) guard !Task.isCancelled else { return } self.complete(pill: pill, provider: provider, finalText: finalText) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift index f8cb31f0ff4..4218c2a8857 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift @@ -82,24 +82,12 @@ final class FloatingBarUsageLimiter: ObservableObject { } var isLimitReached: Bool { - // BYOK users pay their own LLM bill and are never limited. Honor local - // BYOK state so a heartbeat-lagged server quota (allowed=false right - // after activation) can't block chat for a fully-configured BYOK user. - if APIKeyService.isByokActive { - return false - } - guard let quota = serverQuota else { - // No server data yet — allow the query (server will enforce). - return false - } - if quota.allowed { - // Optimistic delta only applies to question-based quotas. - // For cost_usd (Architect/Pro), we can't estimate cost per query - // locally — rely on the server snapshot alone. - guard quota.unit == "questions", let limit = quota.limit else { return false } - return (quota.used + Double(optimisticDelta)) >= limit - } - return true + // Client-side message limit disabled — the backend (which we own) is the + // single source of truth for quota. Never block sends optimistically on + // the client; if the server truly wants to gate a request it returns a + // quota error at request time. This makes every chat surface (main chat, + // floating bar, PTT, AI Clone) unlimited from the client's perspective. + return false } var remainingQueries: Int { diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index e8b4fcd3e00..06f16b4de2a 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -111,6 +111,7 @@ class FloatingControlBarState: NSObject, ObservableObject { @Published var inputViewHeight: CGFloat = 120 @Published var responseContentHeight: CGFloat = 0 @Published private(set) var responseContentHeights: [String: CGFloat] = [:] + @Published private(set) var agentInstallPrompts: [String: AgentInstallPromptState] = [:] @Published var chatHistory: [FloatingChatExchange] = [] @Published var lastConversationActivityAt: Date? = nil @Published var activeAgentChatPillID: UUID? = nil @@ -254,6 +255,23 @@ class FloatingControlBarState: NSObject, ObservableObject { } } + func setAgentInstallPrompt(_ prompt: AgentInstallPromptState, for messageId: String) { + agentInstallPrompts[messageId] = prompt + } + + func agentInstallPrompt(for messageId: String) -> AgentInstallPromptState? { + agentInstallPrompts[messageId] + } + + func updateAgentInstallPrompt( + for messageId: String, + _ update: (inout AgentInstallPromptState) -> Void + ) { + guard var prompt = agentInstallPrompts[messageId] else { return } + update(&prompt) + agentInstallPrompts[messageId] = prompt + } + func measuredContentHeight(for surface: FloatingConversationSurface) -> CGFloat? { responseContentHeights[surface.measurementKey] } @@ -288,6 +306,7 @@ class FloatingControlBarState: NSObject, ObservableObject { displayedQuery = "" currentAIMessage = nil currentQuestionMessageId = nil + agentInstallPrompts = [:] chatHistory = [] showingAIConversation = false showingAIResponse = false diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 6e2ee635642..2df7ec1a427 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -1139,7 +1139,13 @@ struct FloatingControlBarView: View { onSendQuery(message) }, onRate: onRate, - onShareLink: onShareLink + onShareLink: onShareLink, + onAgentInstallAction: { messageId, action in + FloatingControlBarManager.shared.handleAgentInstallPromptAction( + messageId: messageId, + action: action + ) + } ) .transition( .asymmetric( diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 2d09ed0e9f9..16093688cac 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -1816,6 +1816,284 @@ class FloatingControlBarManager { guard let window else { return } window.leaveAgentConversation() } + + func handleAgentInstallPromptAction( + messageId: String, + action: AgentInstallPromptAction + ) { + guard let window, + let prompt = window.state.agentInstallPrompt(for: messageId), + !prompt.status.isBusy + else { return } + + switch action { + case .openDocs: + openAgentInstallDocs(messageId: messageId, plan: prompt.plan) + case .openTerminalSetup: + // OpenClaw-without-Claude-Code fallback: pre-type the model setup + // command in a fresh Terminal window. The service keeps watching + // the config and flips the prompt to connected on its own. + OpenClawConnectService.shared.openTerminalPreloadedWithManualSetup() + case .beginConnection: + // Sign-in plans open a browser (safe, reversible) — no + // confirmation step like the shell installer needs. + if prompt.plan.kind == .authenticate { + runAgentAuthentication(messageId: messageId, plan: prompt.plan) + return + } + let confirmingSince = Date() + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .confirming + $0.confirmingSince = confirmingSince + } + window.resizeToResponseHeightPublic(animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + AgentInstallPromptState.setupConfirmationDelay) { [weak self] in + guard let window = self?.window else { return } + window.state.updateAgentInstallPrompt(for: messageId) { + guard case .confirming = $0.status, + $0.confirmingSince == confirmingSince + else { return } + $0.confirmingSince = nil + } + window.resizeToResponseHeightPublic(animated: true) + } + case .runSetup: + guard let command = prompt.plan.installCommand else { + openAgentInstallDocs(messageId: messageId, plan: prompt.plan) + return + } + guard case .confirming = prompt.status, + prompt.confirmingSince == nil + else { return } + runAgentInstaller(messageId: messageId, plan: prompt.plan, command: command) + } + } + + @discardableResult + func presentAgentInstallPrompt( + for directive: AgentPillsManager.ProviderDirective, + originalRequest: String, + fromVoice: Bool, + provider: ChatProvider? = nil, + logLabel: String = "floating-agent-provider-unavailable" + ) -> Bool { + guard let window else { return false } + let availability = LocalAgentProviderDetector.availability(for: directive.provider) + let assistantText = availability.setupPrompt + let promptProvider = provider ?? activeFloatingProvider() ?? historyChatProvider + let recordedTurn = promptProvider?.recordCompletedTurn( + userText: originalRequest, + assistantText: assistantText, + logLabel: logLabel + ) + let assistantMessage = recordedTurn?.assistant ?? ChatMessage(text: assistantText, sender: .ai) + let retryContext = AgentInstallRetryContext( + originalRequest: originalRequest, + rewrittenQuery: directive.rewrittenQuery, + title: directive.title, + ack: directive.ack, + fromVoice: fromVoice + ) + let plan = availability.needsAuthentication + ? directive.provider.authenticationPlan + : directive.provider.installPlan + window.state.setAgentInstallPrompt( + AgentInstallPromptState(plan: plan, retryContext: retryContext), + for: assistantMessage.id + ) + completeVisibleAgentResponse( + userText: originalRequest, + assistantMessage: assistantMessage, + barWindow: window + ) + return true + } + + private func openAgentInstallDocs(messageId: String, plan: LocalAgentInstallPlan) { + NSWorkspace.shared.open(plan.documentationURL) + window?.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .docsOpened + $0.confirmingSince = nil + } + window?.resizeToResponseHeightPublic(animated: true) + } + + private func runAgentInstaller( + messageId: String, + plan: LocalAgentInstallPlan, + command: String + ) { + guard let window else { return } + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .installing + $0.confirmingSince = nil + } + window.resizeToResponseHeightPublic(animated: true) + + Task { [weak self] in + let result = await AgentInstallCommandRunner.run(command) + guard let self, let window = self.window else { return } + if result.exitCode != 0 { + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .commandFailed(exitCode: result.exitCode, output: result.output) + $0.confirmingSince = nil + } + } else { + let availability = LocalAgentProviderDetector.availability(for: plan.provider) + if availability.isAvailable { + let retryContext = window.state.agentInstallPrompt(for: messageId)?.retryContext + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .connected + $0.confirmingSince = nil + } + if let retryContext { + self.retryAgentInstallPrompt(retryContext, provider: plan.provider) + } + } else if availability.needsAuthentication { + // Binary installed but not yet connected (e.g. OpenClaw + // installs with `--no-onboard`). Swap to the provider's + // connect step instead of reporting "still can't find it". + let retryContext = window.state.agentInstallPrompt(for: messageId)?.retryContext + let authPlan = plan.provider.authenticationPlan + window.state.setAgentInstallPrompt( + AgentInstallPromptState(plan: authPlan, retryContext: retryContext, status: .ready), + for: messageId) + // OpenClaw onboards non-interactively, so continue setup now + // rather than making the user click again. Hermes needs a + // browser step, so it waits for the user to start sign-in. + if plan.provider == .openclaw { + self.runAgentAuthentication(messageId: messageId, plan: authPlan) + } + } else { + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .finishedButMissing(output: result.output) + $0.confirmingSince = nil + } + } + } + window.resizeToResponseHeightPublic(animated: true) + } + } + + /// Drives the Hermes → Nous device-code sign-in from the install-help + /// prompt: opens the verification URL in the browser and mirrors the + /// service's phase into the prompt until the CLI reports approval. + private func runAgentAuthentication( + messageId: String, + plan: LocalAgentInstallPlan + ) { + guard let window else { return } + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .installing + $0.confirmingSince = nil + } + window.resizeToResponseHeightPublic(animated: true) + + // OpenClaw's "connect" is a one-shot non-interactive onboarding run + // (no browser), so it uses its own simpler service + phase mapping. + if plan.provider == .openclaw { + runOpenClawOnboarding(messageId: messageId, plan: plan) + return + } + + // connect() synchronously moves the phase to .starting (or .failed), + // so subscribing after it never replays a stale terminal phase from + // an earlier attempt. + let service = HermesConnectService.shared + service.connect() + agentAuthCancellables[messageId] = service.$phase + .receive(on: DispatchQueue.main) + .sink { [weak self] phase in + guard let self, let window = self.window else { return } + switch phase { + case .idle, .starting: + break + case .waitingForApproval(_, let userCode): + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .waitingForApproval(userCode: userCode) + } + window.resizeToResponseHeightPublic(animated: true) + case .connected: + self.agentAuthCancellables[messageId] = nil + let retryContext = window.state.agentInstallPrompt(for: messageId)?.retryContext + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .connected + } + window.resizeToResponseHeightPublic(animated: true) + if let retryContext { + self.retryAgentInstallPrompt(retryContext, provider: plan.provider) + } + case .failed(let message): + self.agentAuthCancellables[messageId] = nil + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .authFailed(message: message) + } + window.resizeToResponseHeightPublic(animated: true) + } + } + } + + /// Drives the OpenClaw setup (non-interactive `openclaw onboard`) from the + /// install-help prompt: mirrors the service phase into the prompt and, on + /// success, retries the original request. No browser step. + private func runOpenClawOnboarding( + messageId: String, + plan: LocalAgentInstallPlan + ) { + guard let window else { return } + let service = OpenClawConnectService.shared + service.connect() + agentAuthCancellables[messageId] = service.$phase + .receive(on: DispatchQueue.main) + .sink { [weak self] phase in + guard let self, let window = self.window else { return } + switch phase { + case .idle, .onboarding: + break + case .needsManualModelSetup: + // No Claude Code to reuse — show the bring-your-own-key + // Terminal path. The subscription stays live: the service + // polls the config and reports .connected when the user + // finishes, which retries the original request below. + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .needsManualModelSetup( + command: OpenClawConnectService.manualModelSetupCommand) + } + window.resizeToResponseHeightPublic(animated: true) + case .connected: + self.agentAuthCancellables[messageId] = nil + let retryContext = window.state.agentInstallPrompt(for: messageId)?.retryContext + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .connected + } + window.resizeToResponseHeightPublic(animated: true) + if let retryContext { + self.retryAgentInstallPrompt(retryContext, provider: plan.provider) + } + case .failed(let message): + self.agentAuthCancellables[messageId] = nil + window.state.updateAgentInstallPrompt(for: messageId) { + $0.status = .authFailed(message: message) + } + window.resizeToResponseHeightPublic(animated: true) + } + } + } + + private func retryAgentInstallPrompt( + _ retryContext: AgentInstallRetryContext, + provider: AgentPillsManager.DirectedProvider + ) { + _ = AgentPillsManager.shared.spawnFromUserQuery( + retryContext.rewrittenQuery, + model: selectedFloatingModel, + fromVoice: retryContext.fromVoice, + preFetchedTitle: retryContext.title, + preFetchedAck: retryContext.ack, + bridgeHarnessOverride: provider.harnessMode + ) + } + private var agentAuthCancellables: [String: AnyCancellable] = [:] private var pendingNotifications: [FloatingBarNotification] = [] private var notificationDismissWorkItem: DispatchWorkItem? private var notificationWasTemporarilyShown = false @@ -2099,6 +2377,47 @@ class FloatingControlBarManager { ) } + func agentInstallPromptStateForAutomation() -> [String: String] { + guard let window, + let message = window.state.currentAIMessage, + let prompt = window.state.agentInstallPrompt(for: message.id) + else { + return ["present": "false"] + } + return [ + "present": "true", + "provider": prompt.plan.provider.rawValue, + "command": prompt.plan.installCommand ?? "", + "docs": prompt.plan.documentationURL.absoluteString, + "detail": prompt.detailText, + "busy": prompt.status.isBusy ? "true" : "false", + "status": prompt.status.automationValue, + "retry": prompt.retryContext == nil ? "false" : "true", + "kind": prompt.plan.kind == .authenticate ? "authenticate" : "install", + "messageId": message.id, + ] + } + + /// Automation-only: press the current install prompt's primary action + /// (same code path as clicking the button in the floating bar). + func triggerAgentInstallPromptPrimaryAction() -> [String: String] { + guard let window, + let message = window.state.currentAIMessage, + let prompt = window.state.agentInstallPrompt(for: message.id) + else { + return ["error": "no_install_prompt"] + } + handleAgentInstallPromptAction(messageId: message.id, action: prompt.primaryAction) + let label: String + switch prompt.primaryAction { + case .runSetup: label = "runSetup" + case .openTerminalSetup: label = "openTerminalSetup" + case .openDocs: label = "openDocs" + case .beginConnection: label = "beginConnection" + } + return ["triggered": label] + } + func openAskOmiForAutomation(reset: Bool, wait: Bool = true) async -> [String: String] { guard let window else { return ["error": "floating_bar_window_unavailable"] @@ -2559,7 +2878,7 @@ class FloatingControlBarManager { provider: ChatProvider, presentation: QueryPresentation ) async { - let directive = AgentPillsManager.providerDirective( + let directive = await AgentPillsManager.providerDirective( from: message, contextualPreviousRequest: recentVisibleUserRequest(in: barWindow) ) @@ -2589,18 +2908,13 @@ class FloatingControlBarManager { routerTracer?.mark("router_classify", metadata: ["route": "agent", "provider": directive.provider.rawValue]) let availability = LocalAgentProviderDetector.availability(for: directive.provider) guard availability.isAvailable else { - let assistantText = availability.setupPrompt - let recordedTurn = provider.recordCompletedTurn( - userText: message, - assistantText: assistantText, - logLabel: "floating-agent-provider-unavailable" - ) switch presentation { case .visible: - completeVisibleAgentResponse( - userText: message, - assistantMessage: recordedTurn.assistant ?? ChatMessage(text: assistantText, sender: .ai), - barWindow: barWindow + presentAgentInstallPrompt( + for: directive, + originalRequest: message, + fromVoice: presentation.fromVoice, + provider: provider ) case .voiceOnly: barWindow.state.currentQueryFromVoice = false diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift index f343f5560fe..073702e6ee2 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift @@ -666,7 +666,66 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate, AVSpeec } return } - session?.commitInputTurn() + let transcript = turnTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { + session?.commitInputTurn() + return + } + let expectedSession = session + Task { [weak self] in + await self?.commitTurnAfterProviderDirectivePreflight( + transcript: transcript, + expectedSession: expectedSession + ) + } + } + + private func commitTurnAfterProviderDirectivePreflight( + transcript: String, + expectedSession: RealtimeHubSession? + ) async { + guard let expectedSession, session === expectedSession, let activeSession = session else { return } + guard let directive = await AgentPillsManager.providerDirective( + from: transcript, + contextualPreviousRequest: nil + ) else { + activeSession.commitInputTurn() + return + } + + activeSession.abandonInputTurn() + suppressAssistantOutputForCurrentTurn = true + responding = false + let availability = LocalAgentProviderDetector.availability(for: directive.provider) + if availability.isAvailable { + let model = ShortcutSettings.shared.selectedModel.isEmpty + ? ModelQoS.Claude.defaultSelection : ShortcutSettings.shared.selectedModel + let pill = AgentPillsManager.shared.spawnFromUserQuery( + directive.rewrittenQuery, + model: model, + fromVoice: false, + preFetchedTitle: directive.title, + preFetchedAck: directive.ack, + bridgeHarnessOverride: directive.provider.harnessMode) + let ack = "Starting \(directive.provider.displayName) in \(pill.title)." + assistantText = ack + speak(ack) + FloatingControlBarManager.shared.recordVoiceTurn(userText: transcript, assistantText: ack) + log("RealtimeHub[\(providerTag)]: provider directive preflight spawned provider=\(directive.provider.rawValue)") + exitVoiceUI(clearResponseGlow: false) + return + } + + assistantText = availability.setupPrompt + FloatingControlBarManager.shared.presentAgentInstallPrompt( + for: directive, + originalRequest: transcript, + fromVoice: true, + provider: nil, + logLabel: "realtime-agent-provider-unavailable" + ) + speak(directive.provider.setupNeededStatus) + log("RealtimeHub[\(providerTag)]: provider directive preflight unavailable provider=\(directive.provider.rawValue)") } /// Abandon the turn without committing (silent tap / cancel). Must leave NO open @@ -1018,11 +1077,13 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate, AVSpeec switch providerName { case "openclaw": directedProvider = .openclaw case "hermes": directedProvider = .hermes + case "codex": directedProvider = .codex + case "claudecode": directedProvider = .claudeCode case "": directedProvider = nil default: session?.sendToolResult( callId: callId, name: name, - output: "Unsupported agent provider '\(providerName)'. Use 'hermes' or 'openclaw'.") + output: "Unsupported agent provider '\(providerName)'. Use 'hermes', 'openclaw', 'codex', or 'claudeCode'.") return } if let directedProvider { @@ -1031,6 +1092,18 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate, AVSpeec let setupPrompt = availability.setupPrompt assistantText = setupPrompt barState?.isVoiceResponseActive = true + FloatingControlBarManager.shared.presentAgentInstallPrompt( + for: AgentPillsManager.ProviderDirective( + provider: directedProvider, + rewrittenQuery: brief.isEmpty ? turnTranscript : brief, + title: directedProvider.displayName, + ack: "Asking \(directedProvider.displayName)." + ), + originalRequest: turnTranscript.isEmpty ? brief : turnTranscript, + fromVoice: true, + provider: nil, + logLabel: "realtime-agent-provider-unavailable" + ) if !audioReceivedThisTurn { speak(directedProvider.setupNeededStatus) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubTools.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubTools.swift index a1a3dd680f6..9499cbd4a18 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubTools.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubTools.swift @@ -70,19 +70,18 @@ enum HubTool: String { enum RealtimeHubTools { private static func localAgentProviderInstruction() -> String { - let providers: [AgentPillsManager.DirectedProvider] = [.openclaw, .hermes] + let providers: [AgentPillsManager.DirectedProvider] = [.openclaw, .hermes, .codex, .claudeCode] let availability = providers.map { LocalAgentProviderDetector.availability(for: $0) } let available = availability.filter(\.isAvailable).map(\.provider) let unavailable = availability.filter { !$0.isAvailable } if unavailable.isEmpty { - return "If the user asks to use/ask OpenClaw or Hermes, call spawn_agent with provider set to \"openclaw\" or \"hermes\". Treat those as available local providers, not as sessions to inspect." + return "If the user asks to use/ask \(providerDisplayList(providers)), call spawn_agent with provider set to \(providerRawList(providers)). Treat those as available local providers, not as sessions to inspect." } var parts: [String] = [] if !available.isEmpty { - let names = available.map { "\"\($0.rawValue)\"" }.joined(separator: " or ") - parts.append("If the user asks to use/ask \(available.map(\.displayName).joined(separator: " or ")), call spawn_agent with provider set to \(names).") + parts.append("If the user asks to use/ask \(providerDisplayList(available)), call spawn_agent with provider set to \(providerRawList(available)).") } let missingText = unavailable .map { "\($0.provider.displayName): \($0.setupPrompt)" } @@ -92,11 +91,32 @@ enum RealtimeHubTools { } private static func availableDirectedProviderRawValues() -> [String] { - [AgentPillsManager.DirectedProvider.openclaw, .hermes] + [AgentPillsManager.DirectedProvider.openclaw, .hermes, .codex, .claudeCode] .filter { LocalAgentProviderDetector.isAvailable($0) } .map(\.rawValue) } + private static func providerDisplayList(_ providers: [AgentPillsManager.DirectedProvider]) -> String { + naturalList(providers.map(\.displayName)) + } + + private static func providerRawList(_ providers: [AgentPillsManager.DirectedProvider]) -> String { + naturalList(providers.map { "\"\($0.rawValue)\"" }) + } + + private static func naturalList(_ values: [String]) -> String { + switch values.count { + case 0: + return "" + case 1: + return values[0] + case 2: + return values.joined(separator: " or ") + default: + return values.dropLast().joined(separator: ", ") + ", or " + values.last! + } + } + private static func currentCalendarContext(now: Date = Date(), timeZone: TimeZone = .current) -> String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withColonSeparatorInTimeZone] diff --git a/desktop/macos/Desktop/Sources/IMessageReaderService.swift b/desktop/macos/Desktop/Sources/IMessageReaderService.swift new file mode 100644 index 00000000000..6e96c7925c1 --- /dev/null +++ b/desktop/macos/Desktop/Sources/IMessageReaderService.swift @@ -0,0 +1,450 @@ +import AppKit +import Foundation +import GRDB + +// MARK: - Models + +/// A single iMessage/SMS correspondent, keyed by their handle (phone number or email). +/// chat.db has no per-contact display names (those live in the Contacts framework / +/// AddressBook, not the Messages store), so `displayName` falls back to the handle +/// unless the correspondent's chat carries a `display_name` (e.g. a named group thread). +struct IMessageContact: Identifiable, Sendable, Hashable { + /// Stable identifier — the handle string (phone number or email). + let id: String + let displayName: String + let messageCount: Int +} + +/// A single message in a conversation. +struct IMessageMessage: Sendable { + let isFromMe: Bool + let text: String + let date: Date +} + +// MARK: - Platform-agnostic conversion + +extension IMessageContact { + /// Map to the generic contact shape consumed by the AI Clone pipeline. + func asImportedContact() -> ImportedContact { + ImportedContact( + id: id, displayName: displayName, messageCount: messageCount, platform: "imessage") + } +} + +extension IMessageMessage { + /// Map to the generic message shape consumed by the AI Clone pipeline. + func asImportedMessage() -> ImportedMessage { + ImportedMessage(isFromMe: isFromMe, text: text, date: date) + } +} + +enum IMessageReaderError: LocalizedError { + case chatDatabaseNotFound + case fullDiskAccessDenied + case storeUnavailable + + var errorDescription: String? { + switch self { + case .chatDatabaseNotFound: + return "iMessage database (chat.db) not found." + case .fullDiskAccessDenied: + return "Full Disk Access is required to read your Messages history." + case .storeUnavailable: + return "The iMessage data store is unavailable." + } + } +} + +// MARK: - Reader + +actor IMessageReaderService { + static let shared = IMessageReaderService() + + /// `~/Library/Messages/chat.db` — an FDA-protected SQLite (WAL) store. + /// + /// Non-production builds honor the `aiCloneChatDbPathOverride` default so test + /// harnesses (named `omi-*` bundles without Full Disk Access) can point the reader + /// at a snapshot copy of chat.db. Never active on the production bundle. + private var chatDatabaseURL: URL { Self.chatDatabaseURL } + + /// Resolved path to `chat.db`, honoring the non-production snapshot override. Static so + /// the live send/listen path can open the same store the reader does. + static var chatDatabaseURL: URL { + if AppBuild.isNonProduction, + let override = UserDefaults.standard.string(forKey: "aiCloneChatDbPathOverride"), + !override.isEmpty + { + return URL(fileURLWithPath: override) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Messages/chat.db", isDirectory: false) + } + + // MARK: Full Disk Access + // + // macOS provides NO API to *request* Full Disk Access programmatically — unlike + // microphone/screen-recording/accessibility, there is no TCC prompt to trigger. + // The only path is to open System Settings → Privacy → Full Disk Access and have + // the user add the app manually. So "request" here == open that pane. + // + // We *detect* whether access is granted the same way AppState.checkFullDiskAccess() + // does for the badge (probing protected paths), but scoped to what we actually need: + // attempt a read-only open of chat.db and run a trivial query. If TCC blocks us the + // open/read throws (SQLITE_CANTOPEN), which we surface as `.fullDiskAccessDenied`. + + /// Returns true if chat.db can actually be opened and read (i.e. FDA is granted). + func hasFullDiskAccess() -> Bool { + guard FileManager.default.fileExists(atPath: chatDatabaseURL.path) else { + // No chat.db at all (Messages never used) — we can't prove access, treat as not granted. + return false + } + do { + let queue = try makeReadOnlyQueue() + _ = try queue.read { db in try Int.fetchOne(db, sql: "SELECT 1") } + return true + } catch { + return false + } + } + + /// Opens System Settings → Privacy & Security → Full Disk Access so the user can + /// grant access. This is the closest thing to "requesting" FDA that macOS allows. + nonisolated func openFullDiskAccessSettings() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") + { + NSWorkspace.shared.open(url) + } + } + + // MARK: Queries + + /// Top correspondents ranked by total message count (descending). + /// + /// Restricted to 1:1 (direct) threads — a chat with exactly one participant handle. + /// Group threads are excluded entirely, so a group's messages are never credited to + /// its individual members. chat.db has no per-contact names, so `displayName` is the + /// handle (phone/email); real names live in Contacts, not the Messages store. + func topContacts(limit: Int) async throws -> [IMessageContact] { + let queue = try makeReadOnlyQueue() + do { + let contacts: [IMessageContact] = try await queue.read { db in + let rows = try Row.fetchAll( + db, + sql: """ + WITH direct_chats AS ( + SELECT chat_id + FROM chat_handle_join + GROUP BY chat_id + HAVING COUNT(*) = 1 + ) + SELECT + h.id AS handle, + COUNT(DISTINCT cmj.message_id) AS message_count + FROM handle h + JOIN chat_handle_join chj ON chj.handle_id = h.ROWID + JOIN direct_chats dc ON dc.chat_id = chj.chat_id + JOIN chat_message_join cmj ON cmj.chat_id = chj.chat_id + WHERE h.id IS NOT NULL AND h.id <> '' + GROUP BY h.id + ORDER BY message_count DESC + LIMIT ? + """, + arguments: [limit] + ) + + return rows.compactMap { row -> IMessageContact? in + guard let handle = row["handle"] as? String, !handle.isEmpty else { return nil } + + let count = + (row["message_count"] as? Int64).map(Int.init) + ?? (row["message_count"] as? Int ?? 0) + + return IMessageContact(id: handle, displayName: handle, messageCount: count) + } + } + + // Resolve handles → real Contacts names (via the AddressBook, using the same Full Disk + // Access this feature already needs). Unmatched handles keep the raw handle. + let names = await ContactNameResolver.shared.resolveAll(contacts.map { $0.id }) + return contacts.map { contact in + guard let name = names[contact.id], !name.isEmpty else { return contact } + return IMessageContact( + id: contact.id, displayName: name, messageCount: contact.messageCount) + } + } catch let error as IMessageReaderError { + throw error + } catch { + log("IMessageReaderService: topContacts query failed: \(error)") + throw IMessageReaderError.storeUnavailable + } + } + + /// Most-recent messages for a contact (newest first). + /// + /// Restricted to 1:1 (direct) threads only — group threads are excluded, matching + /// `topContacts()`. + func messages(for contact: IMessageContact, limit: Int = 500) async throws -> [IMessageMessage] { + let queue = try makeReadOnlyQueue() + let handle = contact.id + do { + return try await queue.read { db in + let rows = try Row.fetchAll( + db, + sql: """ + WITH direct_chats AS ( + SELECT chat_id + FROM chat_handle_join + GROUP BY chat_id + HAVING COUNT(*) = 1 + ) + SELECT + m.ROWID AS row_id, + m.is_from_me AS is_from_me, + m.text AS text, + m.attributedBody AS attributed_body, + m.date AS date + FROM message m + JOIN chat_message_join cmj ON cmj.message_id = m.ROWID + JOIN direct_chats dc ON dc.chat_id = cmj.chat_id + JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id + JOIN handle h ON h.ROWID = chj.handle_id + WHERE h.id = ? + GROUP BY m.ROWID + ORDER BY m.date DESC + LIMIT ? + """, + arguments: [handle, limit] + ) + + return rows.compactMap { row -> IMessageMessage? in + // Prefer the plain `text` column; when it's null/empty the body lives in the + // binary `attributedBody` (an archived NSAttributedString written by the + // rich-text editor), so decode that instead. Skip only if both are unusable. + let rawText: String? + if let raw = row["text"] as? String, + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + rawText = raw + } else if let blob: Data = row["attributed_body"] { + rawText = Self.decodeAttributedBody(blob) + } else { + rawText = nil + } + guard let rawText, let messageText = Self.sanitizeBody(rawText) else { return nil } + + let isFromMe = + ((row["is_from_me"] as? Int64) ?? Int64(row["is_from_me"] as? Int ?? 0)) == 1 + let rawDate = (row["date"] as? Int64) ?? Int64(row["date"] as? Int ?? 0) + + return IMessageMessage( + isFromMe: isFromMe, + text: messageText, + date: Self.date(fromAppleTimestamp: rawDate) + ) + } + } + } catch let error as IMessageReaderError { + throw error + } catch { + log("IMessageReaderService: messages query failed: \(error)") + throw IMessageReaderError.storeUnavailable + } + } + + // MARK: Helpers + + /// Best-effort plain text for a chat.db message row, preferring the `text` column and + /// falling back to the archived `attributedBody` blob — the same logic `messages(for:)` + /// uses inline. Exposed so the live send/listen path (`IMessageSendService`) decodes + /// incoming messages identically instead of duplicating the typedstream parser. + static func decodeMessageText(text: String?, attributedBody: Data?) -> String? { + let rawText: String? + if let raw = text, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + rawText = raw + } else if let attributedBody { + rawText = decodeAttributedBody(attributedBody) + } else { + rawText = nil + } + guard let rawText else { return nil } + return sanitizeBody(rawText) + } + + private func makeReadOnlyQueue() throws -> DatabaseQueue { + guard FileManager.default.fileExists(atPath: chatDatabaseURL.path) else { + throw IMessageReaderError.chatDatabaseNotFound + } + var configuration = Configuration() + configuration.readonly = true + do { + return try DatabaseQueue(path: chatDatabaseURL.path, configuration: configuration) + } catch { + // The file exists but we can't open it — almost always missing Full Disk Access. + log("IMessageReaderService: Failed to open chat.db read-only: \(error)") + throw IMessageReaderError.fullDiskAccessDenied + } + } + + /// Normalize a raw message body for downstream consumers. Attachments embed the + /// object-replacement character (U+FFFC) in the text; a body that is *only* + /// attachments becomes the explicit "[attachment]" placeholder (so reply pairing + /// keeps the turn), while captioned attachments just lose the marker glyphs. + private static func sanitizeBody(_ raw: String) -> String? { + let stripped = + raw + .replacingOccurrences(of: "\u{FFFC}", with: " ") + .replacingOccurrences(of: "\u{FFFD}", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + if stripped.isEmpty { + return raw.contains("\u{FFFC}") ? "[attachment]" : nil + } + return stripped + } + + /// Extract the plain-string content from a message's `attributedBody` blob. + /// + /// iMessage stores rich-text bodies as an archived `NSAttributedString`. We try the + /// modern secure keyed-archive path first, then a permissive (non-secure) keyed + /// unarchive, then a direct parse of the legacy `streamtyped`/`NSArchiver` typedstream + /// format (very common for iMessage `attributedBody`). Returns nil only when all paths + /// fail, so the caller skips the message rather than dropping recoverable content. + private static func decodeAttributedBody(_ data: Data) -> String? { + guard !data.isEmpty else { return nil } + + func nonEmpty(_ attributed: NSAttributedString) -> String? { + let string = attributed.string.trimmingCharacters(in: .whitespacesAndNewlines) + return string.isEmpty ? nil : string + } + + // Preferred: a modern, secure keyed archive of an NSAttributedString. + if let attributed = try? NSKeyedUnarchiver.unarchivedObject( + ofClass: NSAttributedString.self, from: data) + { + return nonEmpty(attributed) + } + + // Fallback: permit non-secure keyed archives (older / wrapped encodings). + if let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data) { + unarchiver.requiresSecureCoding = false + let root = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) + unarchiver.finishDecoding() + if let attributed = root as? NSAttributedString { + return nonEmpty(attributed) + } + } + + // Final fallback: legacy typedstream (NSArchiver "streamtyped") blob. NSKeyedUnarchiver + // cannot read these, so pull the string payload out of the raw bytes directly. + return decodeTypedStreamString(data) + } + + /// Extract the first string payload from a legacy `streamtyped` (NSArchiver) blob. + /// + /// This is not a general typedstream parser. The layout for an iMessage `attributedBody` + /// places the message text as a length-prefixed byte string right after the `NSString` + /// (or `NSMutableString`) class marker: the class name is followed by framing bytes, then + /// a `+` (0x2b) "bytes" marker, then a variable-length count, then the UTF-8 payload. + /// We locate that marker and read the count immediately preceding the string. + private static func decodeTypedStreamString(_ data: Data) -> String? { + let bytes = [UInt8](data) + guard let classEnd = indexAfterStringClassMarker(bytes) else { return nil } + + // Scan forward for the first 0x2b ('+') bytes-value marker after the class name. + var i = classEnd + while i < bytes.count && bytes[i] != 0x2b { i += 1 } + guard i < bytes.count else { return nil } + i += 1 // step past '+' + + guard let (length, valueStart) = readTypedStreamLength(bytes, at: i), + length > 0, + valueStart + length <= bytes.count + else { return nil } + + let payload = bytes[valueStart..<(valueStart + length)] + let string = String(decoding: payload, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return string.isEmpty ? nil : string + } + + /// Find the byte index just past the earliest `NSString` / `NSMutableString` class-name + /// marker in a typedstream blob. + private static func indexAfterStringClassMarker(_ bytes: [UInt8]) -> Int? { + let markers: [[UInt8]] = [Array("NSMutableString".utf8), Array("NSString".utf8)] + var best: Int? = nil + for marker in markers { + if let start = firstIndex(of: marker, in: bytes) { + let end = start + marker.count + if best == nil || end < best! { best = end } + } + } + return best + } + + /// Read a typedstream variable-length integer. Values 0x00–0x80 are stored inline; + /// 0x81 escapes to a following little-endian UInt16, 0x82 to a UInt32. + private static func readTypedStreamLength(_ bytes: [UInt8], at index: Int) -> ( + length: Int, next: Int + )? { + guard index < bytes.count else { return nil } + switch bytes[index] { + case 0x81: + guard index + 2 < bytes.count else { return nil } + let value = Int(bytes[index + 1]) | (Int(bytes[index + 2]) << 8) + return (value, index + 3) + case 0x82: + guard index + 4 < bytes.count else { return nil } + let value = + Int(bytes[index + 1]) | (Int(bytes[index + 2]) << 8) + | (Int(bytes[index + 3]) << 16) | (Int(bytes[index + 4]) << 24) + return (value, index + 5) + default: + return (Int(bytes[index]), index + 1) + } + } + + /// First index of a byte-sequence needle within a haystack (naive scan; needles are tiny). + private static func firstIndex(of needle: [UInt8], in haystack: [UInt8]) -> Int? { + guard !needle.isEmpty, haystack.count >= needle.count else { return nil } + let last = haystack.count - needle.count + var i = 0 + while i <= last { + if haystack[i] == needle[0] { + var matched = true + var j = 1 + while j < needle.count { + if haystack[i + j] != needle[j] { + matched = false + break + } + j += 1 + } + if matched { return i } + } + i += 1 + } + return nil + } + + /// Convert a chat.db `date` value to a `Date`. + /// + /// The known quirk: iMessage timestamps are measured from the Mac absolute-time epoch + /// (2001-01-01 00:00:00 UTC) — the same reference date Foundation uses — NOT the Unix + /// epoch. macOS High Sierra and later store *nanoseconds*; older versions stored + /// *seconds*. We detect the unit by magnitude and normalize to seconds before building + /// the Date via `timeIntervalSinceReferenceDate`. + private static func date(fromAppleTimestamp raw: Int64) -> Date { + guard raw != 0 else { return Date(timeIntervalSinceReferenceDate: 0) } + let seconds: Double + if raw > 1_000_000_000_000 { + // Nanoseconds since 2001-01-01 (High Sierra+). + seconds = Double(raw) / 1_000_000_000.0 + } else { + // Legacy: seconds since 2001-01-01. + seconds = Double(raw) + } + return Date(timeIntervalSinceReferenceDate: seconds) + } +} diff --git a/desktop/macos/Desktop/Sources/IMessageSendService.swift b/desktop/macos/Desktop/Sources/IMessageSendService.swift new file mode 100644 index 00000000000..489e6de681e --- /dev/null +++ b/desktop/macos/Desktop/Sources/IMessageSendService.swift @@ -0,0 +1,229 @@ +import Foundation +import GRDB + +// MARK: - Errors + +enum IMessageSendError: LocalizedError { + case sendScriptFailed(String) + case automationDenied + case emptyText + + var errorDescription: String? { + switch self { + case .sendScriptFailed(let detail): + return "Couldn't send the iMessage: \(detail)" + case .automationDenied: + return "Omi needs permission to control Messages (System Settings → Privacy → Automation)." + case .emptyText: + return "Nothing to send — the message is empty." + } + } +} + +// MARK: - Service + +/// Native iMessage send + live-receive service, the iMessage counterpart to +/// `TelegramSendService`. Sending goes through Messages.app via AppleScript (there is no +/// public send API); receiving is a lightweight poll of the local `chat.db` for rows that +/// appear after we start listening. +/// +/// Deliberately mirrors `TelegramSendService`'s surface so the AI Clone send-mode layer +/// can treat both platforms uniformly: +/// * `send(toHandle:text:)` +/// * `startListening(onNewMessage:)` / `stopListening()` +/// +/// The listener callback carries the same `(peerKey, fromMe, text, date)` shape; here the +/// peer key is the iMessage handle (phone/email), which is exactly the AI Clone contact id +/// for iMessage contacts (they are stored unprefixed). +actor IMessageSendService { + static let shared = IMessageSendService() + + /// Poll cadence for new-message detection. iMessage has no push API we can tap locally, + /// so we tail the WAL-backed store. 3s keeps replies feeling live without hammering I/O. + private static let pollInterval: TimeInterval = 3.0 + + private var listenerTask: Task? + private var onNewMessage: (@Sendable (String, Bool, String, Date) -> Void)? + /// Highest `message.ROWID` seen so far. Seeded to the current max on first poll so we + /// never replay history — only messages that arrive after listening starts fire. + private var lastRowID: Int64 = 0 + + // MARK: - Sending + + /// Send a plain-text iMessage to `handle` (a phone number or email) via Messages.app. + /// Throws `IMessageSendError` on failure; the caller decides how (or whether) to surface it. + func send(toHandle handle: String, text: String) async throws { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw IMessageSendError.emptyText } + + // Handle + text are passed as argv (never interpolated into the script source) so a + // message body can't break out into AppleScript. + let script = """ + on run argv + set targetHandle to item 1 of argv + set messageText to item 2 of argv + tell application "Messages" + set targetService to 1st service whose service type = iMessage + set targetBuddy to buddy targetHandle of targetService + send messageText to targetBuddy + end tell + end run + """ + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-", handle, trimmed] + + let stdin = Pipe() + let stderr = Pipe() + process.standardInput = stdin + process.standardOutput = Pipe() + process.standardError = stderr + + do { + try process.run() + } catch { + throw IMessageSendError.sendScriptFailed(error.localizedDescription) + } + stdin.fileHandleForWriting.write(Data(script.utf8)) + try? stdin.fileHandleForWriting.close() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // osascript surfaces the Automation-permission refusal as error -1743. + if errText.contains("-1743") || errText.localizedCaseInsensitiveContains("not allowed") { + throw IMessageSendError.automationDenied + } + throw IMessageSendError.sendScriptFailed(errText.isEmpty ? "osascript failed" : errText) + } + } + + // MARK: - Listening + + /// Begin polling `chat.db` for new direct-thread messages. The callback fires for both + /// directions (`fromMe` distinguishes them), mirroring `TelegramSendService`. Idempotent — + /// a second call just replaces the callback; the single poll loop keeps running. + func startListening( + onNewMessage: @escaping @Sendable ( + _ handle: String, _ fromMe: Bool, _ text: String, _ date: Date) -> Void + ) { + self.onNewMessage = onNewMessage + guard listenerTask == nil else { return } + + // Seed the baseline synchronously-ish on the first loop tick, then emit only newer rows. + lastRowID = 0 + listenerTask = Task { [weak self] in + while !Task.isCancelled { + await self?.poll() + try? await Task.sleep(nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)) + } + } + } + + func stopListening() { + listenerTask?.cancel() + listenerTask = nil + onNewMessage = nil + lastRowID = 0 + } + + /// One poll pass: read direct-thread messages with `ROWID > lastRowID`, emit them, and + /// advance the cursor. The very first pass only establishes the cursor (no emit) so we + /// don't replay the whole history the moment listening starts. + private func poll() async { + guard onNewMessage != nil else { return } + let path = IMessageReaderService.chatDatabaseURL.path + guard FileManager.default.fileExists(atPath: path) else { return } + + var configuration = Configuration() + configuration.readonly = true + + // First pass: cheaply anchor the cursor to the newest row so we never replay history. + if lastRowID == 0 { + do { + let queue = try DatabaseQueue(path: path, configuration: configuration) + let maxRow = try await queue.read { db in + try Int64.fetchOne(db, sql: "SELECT MAX(ROWID) FROM message") ?? 0 + } + lastRowID = max(1, maxRow) + } catch { + // Couldn't open (WAL busy / FDA) — retry next tick without advancing. + } + return + } + + let rows: [Row] + do { + let queue = try DatabaseQueue(path: path, configuration: configuration) + let cursor = lastRowID + rows = try await queue.read { db in + try Row.fetchAll( + db, + sql: """ + WITH direct_chats AS ( + SELECT chat_id + FROM chat_handle_join + GROUP BY chat_id + HAVING COUNT(*) = 1 + ) + SELECT + m.ROWID AS row_id, + m.is_from_me AS is_from_me, + m.text AS text, + m.attributedBody AS attributed_body, + m.date AS date, + h.id AS handle + FROM message m + JOIN chat_message_join cmj ON cmj.message_id = m.ROWID + JOIN direct_chats dc ON dc.chat_id = cmj.chat_id + JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id + JOIN handle h ON h.ROWID = chj.handle_id + WHERE m.ROWID > ? + GROUP BY m.ROWID + ORDER BY m.ROWID ASC + """, + arguments: [cursor] + ) + } + } catch { + // Transient (WAL busy, FDA revoked mid-session) — try again next tick, no banner. + return + } + + guard !rows.isEmpty else { return } + + var maxRow = lastRowID + var events: [(handle: String, fromMe: Bool, text: String, date: Date)] = [] + for row in rows { + let rowID = (row["row_id"] as? Int64) ?? Int64(row["row_id"] as? Int ?? 0) + maxRow = max(maxRow, rowID) + guard let handle = row["handle"] as? String, !handle.isEmpty else { continue } + let attributed: Data? = row["attributed_body"] + guard + let text = IMessageReaderService.decodeMessageText( + text: row["text"] as? String, attributedBody: attributed) + else { continue } + let fromMe = + ((row["is_from_me"] as? Int64) ?? Int64(row["is_from_me"] as? Int ?? 0)) == 1 + let rawDate = (row["date"] as? Int64) ?? Int64(row["date"] as? Int ?? 0) + events.append((handle, fromMe, text, Self.date(fromAppleTimestamp: rawDate))) + } + + lastRowID = maxRow + guard let onNewMessage else { return } + for event in events { + onNewMessage(event.handle, event.fromMe, event.text, event.date) + } + } + + /// Same Apple-absolute-time conversion the reader uses (nanoseconds since 2001 on + /// High Sierra+, seconds on older stores). + private static func date(fromAppleTimestamp raw: Int64) -> Date { + guard raw != 0 else { return Date(timeIntervalSinceReferenceDate: 0) } + let seconds = raw > 1_000_000_000_000 ? Double(raw) / 1_000_000_000.0 : Double(raw) + return Date(timeIntervalSinceReferenceDate: seconds) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 081b3d80d9c..89a1f1d2144 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -514,6 +514,9 @@ struct DesktopHomeView: View { var visibleRawValues: Set = [ SidebarNavItem.dashboard.rawValue, SidebarNavItem.rewind.rawValue, + // AI Clone is tier 0 (always available) — keep it out of the tier-gated + // redirect so navigating to it from the dashboard card isn't bounced. + SidebarNavItem.aiClone.rawValue, ] if currentTierLevel >= 2 { visibleRawValues.insert(SidebarNavItem.memories.rawValue) } if currentTierLevel >= 3 { visibleRawValues.insert(SidebarNavItem.tasks.rawValue) } @@ -631,6 +634,8 @@ struct DesktopHomeView: View { return .settings case "permissions": return .permissions + case "ai_clone": + return .aiClone case "help": return .help default: @@ -1149,6 +1154,8 @@ private struct PageContentView: View { ) case 10: PermissionsPage(appState: appState) + case 11: + AIClonePage() case 12: HelpPage() default: diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/AIClonePage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/AIClonePage.swift new file mode 100644 index 00000000000..6ad195ee376 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/AIClonePage.swift @@ -0,0 +1,2724 @@ +import AppKit +import SwiftUI + +/// AI Clone page — an AI-powered messaging assistant that learns to reply to your +/// contacts in your voice. Contacts are the user's real top correspondents (ranked by +/// message count) from iMessage (read locally via `IMessageReaderService`) plus any +/// Telegram/WhatsApp exports the user has imported this session. +struct AIClonePage: View { + private enum LoadState: Equatable { + case loading + case loaded + case needsFullDiskAccess + case empty + case failed(String) + } + + /// Send-mode coordinator (per-contact mode, autonomous kill switch, drafts, sent log). + @ObservedObject private var sendMode = AICloneSendModeService.shared + /// Non-nil while the "Recent Sent Messages" sheet is open. + @State private var showSentLog = false + + @State private var state: LoadState = .loading + @State private var contacts: [ImportedContact] = [] + @State private var selectedHandles: Set = [] + /// How many top contacts to auto-select. Defaults to 5; re-applied whenever changed. + @State private var autoSelectCount = 5 + /// Bumped to force `.task` to re-run (e.g. after the user grants Full Disk Access). + @State private var reloadToken = UUID() + + /// Generated personas keyed by contact id (hydrated from disk on load). + @State private var personas: [String: ContactPersona] = [:] + /// Contact ids currently generating a persona (drives the per-row spinner). + @State private var trainingHandles: Set = [] + /// Last training error per contact id, shown inline on that row. + @State private var trainingErrors: [String: String] = [:] + /// Non-nil while the chat sheet is open for a trained contact. + @State private var chatTarget: AICloneChatTarget? + /// Automation-requested chat open that arrived before contacts finished loading. + @State private var pendingAutomationChatId: String? + /// Per-contact backtest UI state (progress while running, result when done). + @State private var backtestStates: [String: AICloneBacktestUIState] = [:] + /// Non-nil while the backtest-results detail sheet is open. + @State private var backtestDetail: AICloneBacktestDetail? + + /// Non-nil while the Telegram "which one is you" sheet is open. + @State private var telegramSenderPicker: TelegramSenderPickerState? + /// Non-nil while the WhatsApp "which one is you" sheet is open. + @State private var whatsAppSenderPicker: WhatsAppSenderPickerState? + @State private var telegramImportError: String? + @State private var whatsAppImportError: String? + + /// WhatsApp live-link state (QR scanning / linked), mirrored from the sidecar. + @ObservedObject private var whatsAppLink = WhatsAppLinkModel.shared + /// True while the WhatsApp QR-linking sheet is open. + @State private var showWhatsAppLinkSheet = false + /// Non-nil while the one-time "WhatsApp Autonomous is risky" confirmation is up: the + /// contact whose switch to Autonomous awaits explicit acknowledgment. + @State private var whatsAppAutonomousCandidate: ImportedContact? + + private var maxSelectable: Int { contacts.count } + private var hasTelegramContacts: Bool { contacts.contains { $0.platform == "telegram" } } + private var hasWhatsAppContacts: Bool { contacts.contains { $0.platform == "whatsapp" } } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + header + + autonomousBanner + + importControls + + if !sendMode.pendingDrafts.isEmpty { + pendingDraftsSection + } + + content + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(OmiColors.backgroundPrimary) + .task(id: reloadToken) { await load() } + // Deliberately NO stopListening on disappear: Draft-Review/Autonomous must keep + // working after the user navigates away — the listeners are app-level, owned by + // AICloneSendModeService (bootstrapped at launch), not by this page's lifecycle. + .onReceive(NotificationCenter.default.publisher(for: .aiCloneOpenChatRequested)) { note in + guard let id = note.userInfo?["contactId"] as? String else { return } + openChatViaAutomation(id) + } + .onReceive(NotificationCenter.default.publisher(for: .aiCloneCloseChatRequested)) { _ in + chatTarget = nil + } + .onChange(of: chatTarget?.id) { + // Authoritative presentation signal: the sheet's own onDisappear is unreliable on + // macOS (sheet hosts get cached), so lifecycle state is owned here. + AICloneChatAutomation.shared.activeContactId = chatTarget?.contact.id + if chatTarget == nil { + AICloneChatAutomation.shared.liveSnapshot = nil + } + } + .sheet(isPresented: $showSentLog) { + AICloneSentLogSheet() + } + .sheet(item: $chatTarget) { target in + AICloneChatSheet(contact: target.contact, persona: target.persona) + } + .sheet(item: $backtestDetail) { detail in + AICloneBacktestSheet(contact: detail.contact, result: detail.result) + } + .sheet(item: $telegramSenderPicker) { picker in + TelegramSenderSheet(senders: picker.senders) { chosen in + telegramSenderPicker = nil + Task { + await TelegramImportService.shared.setSelfID(chosen.senderID ?? chosen.id) + await refreshTelegramContacts() + } + } + } + .sheet(item: $whatsAppSenderPicker) { picker in + WhatsAppSenderSheet(options: picker.options, preselected: picker.preselected) { chosen in + whatsAppSenderPicker = nil + Task { + await WhatsAppImportService.shared.setSelfName(chosen) + await refreshWhatsAppContacts() + } + } + } + .sheet(isPresented: $showWhatsAppLinkSheet) { + WhatsAppLinkSheet() + } + .alert( + "Enable Autonomous for WhatsApp?", + isPresented: Binding( + get: { whatsAppAutonomousCandidate != nil }, + set: { if !$0 { whatsAppAutonomousCandidate = nil } } + ), + presenting: whatsAppAutonomousCandidate + ) { contact in + Button("I Understand the Risk — Enable", role: .destructive) { + sendMode.acknowledgeWhatsAppAutonomousRisk() + sendMode.setMode(.autonomous, for: contact.id) + whatsAppAutonomousCandidate = nil + } + Button("Cancel", role: .cancel) { + whatsAppAutonomousCandidate = nil + } + } message: { _ in + Text( + "Omi connects to WhatsApp through Linked Devices using an unofficial method — " + + "WhatsApp has no official API for personal accounts. Automated sending is " + + "against WhatsApp's terms and carries some risk of your account being " + + "flagged or banned. This one-time confirmation is required before any " + + "WhatsApp contact can be set to Autonomous. Nothing sends while the global " + + "Autonomous switch stays paused." + ) + } + } + + /// Route a mode-picker selection through the WhatsApp-autonomous safety gate: the first + /// time any WhatsApp contact is switched to Autonomous, the explicit unofficial-connection + /// risk confirmation must be accepted before the mode applies. + private func requestModeChange(_ newMode: SendMode, for contact: ImportedContact) { + if AICloneSendModeService.requiresWhatsAppAutonomousAcknowledgment( + mode: newMode, contactId: contact.id, + acknowledged: sendMode.whatsAppAutonomousAcknowledged) + { + whatsAppAutonomousCandidate = contact + return + } + sendMode.setMode(newMode, for: contact.id) + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("AI Clone") + .scaledFont(size: 28, weight: .bold) + .foregroundColor(OmiColors.textPrimary) + + Text("Your AI-powered messaging assistant") + .scaledFont(size: 15, weight: .regular) + .foregroundColor(OmiColors.textSecondary) + } + } + + // MARK: - Autonomous kill switch banner + + /// Always-visible global control for autonomous sending. Warning-colored (the one place + /// the AI Clone UI uses `OmiColors.warning` as an accent) because flipping it ACTIVE lets + /// the clone message real people on its own. + private var autonomousBanner: some View { + let active = !sendMode.isPaused + return HStack(spacing: 14) { + Image(systemName: active ? "bolt.fill" : "pause.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(active ? OmiColors.warning : OmiColors.textSecondary) + + VStack(alignment: .leading, spacing: 2) { + Text("Autonomous Sending: \(active ? "ACTIVE" : "PAUSED")") + .scaledFont(size: 14, weight: .bold) + .foregroundColor(active ? OmiColors.warning : OmiColors.textPrimary) + Text( + active + ? "The clone can send replies to real people on its own. Turn this off to stop." + : "Autonomous replies are paused. Draft-Review and manual sending still work." + ) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button(action: { showSentLog = true }) { + HStack(spacing: 5) { + Image(systemName: "paperplane") + .font(.system(size: 11, weight: .semibold)) + Text("Sent\(sendMode.sentLog.isEmpty ? "" : " (\(sendMode.sentLog.count))")") + .scaledFont(size: 12, weight: .semibold) + } + .foregroundColor(OmiColors.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + + Toggle( + "", + isOn: Binding( + get: { !sendMode.isPaused }, + set: { sendMode.setPaused(!$0) } + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .tint(OmiColors.warning) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(active ? OmiColors.warning.opacity(0.12) : OmiColors.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(active ? OmiColors.warning.opacity(0.5) : OmiColors.border, lineWidth: 1) + ) + } + + // MARK: - Pending drafts (Draft-Review approval queue) + + private var pendingDraftsSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "tray.full") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(OmiColors.textSecondary) + Text("Pending replies (\(sendMode.pendingDrafts.count))") + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + } + + VStack(spacing: 8) { + ForEach(sendMode.pendingDrafts) { draft in + AIClonePendingDraftRow(draft: draft) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + } + + // MARK: - Import controls (Telegram / WhatsApp) + + private var importControls: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + importButton(title: "Import Telegram", systemImage: "paperplane.fill", action: importTelegram) + if hasTelegramContacts { + changeButton(action: changeTelegramSelf) + } + + importButton( + title: "Import WhatsApp", systemImage: "phone.bubble.left.fill", action: importWhatsApp) + if hasWhatsAppContacts { + changeButton(action: changeWhatsAppSelf) + } + + whatsAppLinkButton + + Spacer() + } + + if let telegramImportError { + Text(telegramImportError) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + } + if let whatsAppImportError { + Text(whatsAppImportError) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + } + } + } + + private func importButton(title: String, systemImage: String, action: @escaping () -> Void) + -> some View + { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + Text(title) + .scaledFont(size: 13, weight: .semibold) + } + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + /// Opens the QR linking sheet. Label reflects the live link state so "Linked" is visible + /// at a glance without opening the sheet. + private var whatsAppLinkButton: some View { + Button(action: { showWhatsAppLinkSheet = true }) { + HStack(spacing: 6) { + Image( + systemName: whatsAppLink.state.isLinked ? "checkmark.circle.fill" : "qrcode") + .font(.system(size: 12, weight: .semibold)) + Text(whatsAppLink.state.isLinked ? "WhatsApp Linked" : "Link WhatsApp") + .scaledFont(size: 13, weight: .semibold) + } + .foregroundColor( + whatsAppLink.state.isLinked ? OmiColors.success : OmiColors.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + .help("Connect your WhatsApp account (Linked Devices) so the clone can send and receive") + } + + private func changeButton(action: @escaping () -> Void) -> some View { + Button(action: action) { + Text("Change") + .scaledFont(size: 12, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + } + .buttonStyle(.plain) + } + + // MARK: - Content (state machine) + + @ViewBuilder + private var content: some View { + switch state { + case .loading: + centered { + ProgressView() + .scaleEffect(1.2) + .tint(.white) + Text("Reading your Messages history…") + .scaledFont(size: 14, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + + case .needsFullDiskAccess: + fullDiskAccessPrompt + + case .empty: + centered { + Image(systemName: "message") + .font(.system(size: 34, weight: .regular)) + .foregroundColor(OmiColors.textQuaternary) + Text("No conversations found") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text( + "Once you have direct message threads in Messages, or import a Telegram/WhatsApp " + + "export, your top contacts will appear here." + ) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 360) + } + + case .failed(let message): + centered { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32, weight: .regular)) + .foregroundColor(OmiColors.warning) + Text("Couldn't load contacts") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text(message) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 360) + reloadButton(title: "Try Again") + } + + case .loaded: + loadedContent + } + } + + private var loadedContent: some View { + VStack(alignment: .leading, spacing: 16) { + autoSelectControl + + ScrollView { + LazyVStack(spacing: 10) { + ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in + AICloneContactRow( + rank: index + 1, + contact: contact, + isSelected: selectedHandles.contains(contact.id), + isTraining: trainingHandles.contains(contact.id), + persona: personas[contact.id], + errorMessage: trainingErrors[contact.id], + backtest: backtestStates[contact.id], + sendMode: sendMode.mode(for: contact.id), + onSetMode: { newMode in requestModeChange(newMode, for: contact) }, + onToggle: { toggleSelection(contact) }, + onTrain: { train(contact) }, + onPreviewChat: { + if let persona = personas[contact.id] { + chatTarget = AICloneChatTarget(contact: contact, persona: persona) + } + }, + onRunBacktest: { runBacktest(contact) }, + onShowBacktestDetail: { + if case .done(let result) = backtestStates[contact.id] { + backtestDetail = AICloneBacktestDetail(contact: contact, result: result) + } + } + ) + } + } + .padding(.bottom, 8) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Auto-select control + + private var autoSelectControl: some View { + HStack(spacing: 12) { + Text("Auto-select top") + .scaledFont(size: 14, weight: .medium) + .foregroundColor(OmiColors.textSecondary) + + Text("\(autoSelectCount)") + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .frame(minWidth: 22) + + Stepper("", value: $autoSelectCount, in: 0...max(0, maxSelectable)) + .labelsHidden() + .onChange(of: autoSelectCount) { applyTopXSelection() } + + Text("contact\(autoSelectCount == 1 ? "" : "s") by message count") + .scaledFont(size: 14, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + + Spacer() + + Text("\(selectedHandles.count) selected") + .scaledFont(size: 13, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + } + + // MARK: - Full Disk Access prompt + + private var fullDiskAccessPrompt: some View { + centered { + Image(systemName: "lock.shield") + .font(.system(size: 34, weight: .regular)) + .foregroundColor(OmiColors.textSecondary) + + Text("Full Disk Access required") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + + Text( + "Omi reads your Messages history locally on this Mac to learn how you write. " + + "Grant Full Disk Access in System Settings, then reload — or import a " + + "Telegram/WhatsApp export above instead." + ) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 380) + + HStack(spacing: 10) { + Button(action: { IMessageReaderService.shared.openFullDiskAccessSettings() }) { + Text("Open System Settings") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.backgroundPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.textPrimary)) + } + .buttonStyle(.plain) + + reloadButton(title: "Reload") + } + .padding(.top, 4) + } + } + + // MARK: - Reusable pieces + + private func reloadButton(title: String) -> some View { + Button(action: { reloadToken = UUID() }) { + Text(title) + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(OmiColors.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func centered(@ViewBuilder _ inner: () -> Inner) -> some View { + VStack(spacing: 12) { + inner() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Data + selection + + private func load() async { + state = .loading + personas = await AIClonePersonaService.shared.allPersonas() + do { + let result = try await IMessageReaderService.shared.topContacts(limit: 20) + // Re-fetch after the await so imports finished during load aren't clobbered. + let otherContacts = await importedPlatformContacts() + finishLoad(result.map { $0.asImportedContact() } + otherContacts) + } catch IMessageReaderError.fullDiskAccessDenied { + let otherContacts = await importedPlatformContacts() + if otherContacts.isEmpty { + state = .needsFullDiskAccess + } else { + finishLoad(otherContacts) + } + } catch IMessageReaderError.chatDatabaseNotFound { + let otherContacts = await importedPlatformContacts() + finishLoad(otherContacts) + } catch { + state = .failed(error.localizedDescription) + } + } + + /// Session-imported Telegram/WhatsApp contacts (empty until the user imports one). + private func importedPlatformContacts() async -> [ImportedContact] { + async let telegram = TelegramImportService.shared.topContacts(limit: 20) + async let whatsApp = WhatsAppImportService.shared.topContacts(limit: 20) + return await telegram + whatsApp + } + + private func finishLoad(_ imported: [ImportedContact]) { + contacts = imported.sorted { $0.messageCount > $1.messageCount } + if contacts.isEmpty { + selectedHandles = [] + state = .empty + return + } + autoSelectCount = min(5, contacts.count) + applyTopXSelection() + state = .loaded + refreshActiveContacts() + sendMode.startListening() + if let pending = pendingAutomationChatId { + pendingAutomationChatId = nil + openChatViaAutomation(pending) + } + } + + /// Open the chat sheet for a bridge-requested contact; parked until contacts load when + /// the request lands mid-load (e.g. right after a navigate to this page). + private func openChatViaAutomation(_ contactId: String) { + if let contact = contacts.first(where: { $0.id == contactId }), + let persona = personas[contact.id] + { + chatTarget = AICloneChatTarget(contact: contact, persona: persona) + } else if state == .loading { + pendingAutomationChatId = contactId + } + } + + /// Push the current trained contacts (contact + persona) into the send-mode coordinator so + /// its live listeners can route incoming messages. Called on load and after each train. + private func refreshActiveContacts() { + let entries = contacts.compactMap { contact -> (contact: ImportedContact, persona: ContactPersona)? in + guard let persona = personas[contact.id] else { return nil } + return (contact, persona) + } + sendMode.updateActiveContacts(entries) + } + + /// Select exactly the top-N contacts by rank. Called on load and whenever the user + /// changes N via the stepper; per-row toggles override this afterward. + private func applyTopXSelection() { + let clamped = max(0, min(autoSelectCount, contacts.count)) + selectedHandles = Set(contacts.prefix(clamped).map { $0.id }) + } + + private func toggleSelection(_ contact: ImportedContact) { + if selectedHandles.contains(contact.id) { + selectedHandles.remove(contact.id) + } else { + selectedHandles.insert(contact.id) + } + } + + /// Generate a persona for one contact. Runs on the MainActor-isolated view, so state + /// mutations after the `await` are safe. Errors surface inline on the row. + private func train(_ contact: ImportedContact) { + guard !trainingHandles.contains(contact.id) else { return } + trainingHandles.insert(contact.id) + trainingErrors[contact.id] = nil + Task { + if contact.platform == "telegram", + await TelegramImportService.shared.hasSelfIdentity() == false + { + telegramSenderPicker = TelegramSenderPickerState( + senders: await TelegramImportService.shared.currentSenders()) + trainingHandles.remove(contact.id) + return + } + if contact.platform == "whatsapp", + await WhatsAppImportService.shared.hasSelfIdentity() == false + { + let options = await WhatsAppImportService.shared.currentSenderOptions() + let preselected = options.first(where: \.appearsInEveryChat)?.name + whatsAppSenderPicker = WhatsAppSenderPickerState(options: options, preselected: preselected) + trainingHandles.remove(contact.id) + return + } + do { + let messages = try await Self.loadMessages(for: contact, limit: 500) + let persona = try await AIClonePersonaService.shared.generatePersona( + for: contact, messages: messages) + personas[contact.id] = persona + refreshActiveContacts() + } catch { + trainingErrors[contact.id] = error.localizedDescription + } + trainingHandles.remove(contact.id) + } + } + + /// Fetch this contact's history, branching by platform. `fileprivate` so + /// `AICloneChatSheet` in this file can reuse the same loading logic. + fileprivate static func loadMessages( + for contact: ImportedContact, limit: Int = 500 + ) async throws -> [ImportedMessage] { + try await AICloneMessageLoader.loadMessages(for: contact, limit: limit) + } + + /// Run the full backtest + refine loop for one contact, streaming progress into the row. + private func runBacktest(_ contact: ImportedContact) { + if case .running = backtestStates[contact.id] { return } + backtestStates[contact.id] = .running( + AICloneBacktestProgressUI(iteration: 1, maxIterations: 5, phase: "Starting", latestAverage: nil)) + + Task { + if contact.platform == "telegram", + await TelegramImportService.shared.hasSelfIdentity() == false + { + telegramSenderPicker = TelegramSenderPickerState( + senders: await TelegramImportService.shared.currentSenders()) + backtestStates[contact.id] = nil + return + } + if contact.platform == "whatsapp", + await WhatsAppImportService.shared.hasSelfIdentity() == false + { + let options = await WhatsAppImportService.shared.currentSenderOptions() + let preselected = options.first(where: \.appearsInEveryChat)?.name + whatsAppSenderPicker = WhatsAppSenderPickerState(options: options, preselected: preselected) + backtestStates[contact.id] = nil + return + } + do { + let messages = try await Self.loadMessages(for: contact, limit: 500) + let (persona, result) = try await AICloneBacktestService.shared.trainToTarget( + for: contact, + messages: messages, + onProgress: { progress in + Task { @MainActor in + // Only overwrite while still running (don't clobber a finished result). + if case .running = backtestStates[contact.id] { + backtestStates[contact.id] = .running( + AICloneBacktestProgressUI( + iteration: progress.iteration, + maxIterations: progress.maxIterations, + phase: progress.phase, + latestAverage: progress.latestAverage)) + } + } + } + ) + // trainToTarget persisted the winning persona; refresh the row's cached copy. + personas[contact.id] = persona + refreshActiveContacts() + backtestStates[contact.id] = .done(result) + } catch { + backtestStates[contact.id] = .failed(error.localizedDescription) + } + } + } + + // MARK: - Import actions + + private func importTelegram() { + let panel = NSOpenPanel() + panel.message = "Select your Telegram export (result.json, or the export folder)" + panel.prompt = "Import" + panel.allowedContentTypes = [.json] + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + + telegramImportError = nil + Task { + do { + let senders = try await TelegramImportService.shared.importExport(at: url) + await refreshTelegramContacts() + if !senders.isEmpty { + telegramSenderPicker = TelegramSenderPickerState(senders: senders) + } + } catch { + telegramImportError = error.localizedDescription + } + } + } + + private func changeTelegramSelf() { + Task { + let senders = await TelegramImportService.shared.currentSenders() + guard !senders.isEmpty else { return } + telegramSenderPicker = TelegramSenderPickerState(senders: senders) + } + } + + private func importWhatsApp() { + let panel = NSOpenPanel() + panel.message = "Select one or more WhatsApp \"Export Chat\" .txt files" + panel.prompt = "Import" + panel.allowedContentTypes = [.plainText] + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + guard panel.runModal() == .OK, !panel.urls.isEmpty else { return } + let urls = panel.urls + + whatsAppImportError = nil + Task { + do { + let options = try await WhatsAppImportService.shared.importFiles(at: urls) + if !options.isEmpty { + let preselected = options.first(where: \.appearsInEveryChat)?.name + whatsAppSenderPicker = WhatsAppSenderPickerState(options: options, preselected: preselected) + } + } catch { + whatsAppImportError = error.localizedDescription + } + // Show any chats that imported successfully even if a later file failed mid-batch. + await refreshWhatsAppContacts() + } + } + + private func changeWhatsAppSelf() { + Task { + let options = await WhatsAppImportService.shared.currentSenderOptions() + guard !options.isEmpty else { return } + let preselected = options.first(where: \.appearsInEveryChat)?.name + whatsAppSenderPicker = WhatsAppSenderPickerState(options: options, preselected: preselected) + } + } + + private func refreshTelegramContacts() async { + let imported = await TelegramImportService.shared.topContacts(limit: 20) + mergeContacts(imported) + } + + private func refreshWhatsAppContacts() async { + let imported = await WhatsAppImportService.shared.topContacts(limit: 20) + mergeContacts(imported) + } + + /// Merge freshly-imported contacts into the existing list (by id) without disturbing + /// contacts from other platforms already showing. + private func mergeContacts(_ imported: [ImportedContact]) { + guard !imported.isEmpty else { return } + var byID = Dictionary(uniqueKeysWithValues: contacts.map { ($0.id, $0) }) + for contact in imported { byID[contact.id] = contact } + contacts = byID.values.sorted { $0.messageCount > $1.messageCount } + if state != .loaded { state = .loaded } + } +} + +// MARK: - Sender picker sheets + +/// Identifies an in-progress Telegram "which one is you" prompt. +private struct TelegramSenderPickerState: Identifiable { + let id = UUID() + let senders: [TelegramSender] +} + +/// Identifies an in-progress WhatsApp "which one is you" prompt. +private struct WhatsAppSenderPickerState: Identifiable { + let id = UUID() + let options: [WhatsAppSenderOption] + let preselected: String? +} + +private struct TelegramSenderSheet: View { + let senders: [TelegramSender] + let onSelect: (TelegramSender) -> Void + + var body: some View { + SenderPickerSheet( + subtitle: "Pick your name so Omi knows which Telegram messages are yours." + ) { + ForEach(senders) { sender in + SenderPickerRow( + name: sender.name ?? "Unknown", messageCount: sender.messageCount, badge: nil, + onSelect: { onSelect(sender) }) + } + } + } +} + +private struct WhatsAppSenderSheet: View { + let options: [WhatsAppSenderOption] + let preselected: String? + let onSelect: (String) -> Void + + var body: some View { + SenderPickerSheet( + subtitle: "Pick your name so Omi knows which WhatsApp messages are yours." + ) { + ForEach(options) { option in + SenderPickerRow( + name: option.name, messageCount: option.messageCount, + badge: option.name == preselected ? "likely you" : nil, + onSelect: { onSelect(option.name) }) + } + } + } +} + +/// Shared chrome for the Telegram/WhatsApp "which one is you" sheets. +private struct SenderPickerSheet: View { + let subtitle: String + let rows: Rows + + init(subtitle: String, @ViewBuilder rows: () -> Rows) { + self.subtitle = subtitle + self.rows = rows() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Which one is you?") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text(subtitle) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + .padding(16) + Divider().overlay(OmiColors.border) + ScrollView { + LazyVStack(spacing: 6) { + rows + } + .padding(16) + } + } + .frame(width: 380, height: 420) + .background(OmiColors.backgroundPrimary) + } +} + +private struct SenderPickerRow: View { + let name: String + let messageCount: Int + let badge: String? + let onSelect: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + Button(action: { + onSelect() + dismiss() + }) { + HStack(spacing: 8) { + Text(name) + .scaledFont(size: 14, weight: .medium) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1) + + if let badge { + Text(badge) + .scaledFont(size: 10, weight: .semibold) + .foregroundColor(OmiColors.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(OmiColors.backgroundTertiary)) + } + + Spacer() + + Text("\(messageCount) messages") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 10).fill(OmiColors.backgroundSecondary)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Contact Row + +private struct AICloneContactRow: View { + let rank: Int + let contact: ImportedContact + let isSelected: Bool + let isTraining: Bool + let persona: ContactPersona? + let errorMessage: String? + let backtest: AICloneBacktestUIState? + let sendMode: SendMode + let onSetMode: (SendMode) -> Void + let onToggle: () -> Void + let onTrain: () -> Void + let onPreviewChat: () -> Void + let onRunBacktest: () -> Void + let onShowBacktestDetail: () -> Void + + @State private var isHovered = false + + private var isTrained: Bool { persona != nil } + + /// Small platform badge shown next to the name for non-iMessage sources. No purple + /// (per AGENTS.md) — neutral white/gray SF Symbols. + private var platformIcon: String? { + switch contact.platform { + case "telegram": return "paperplane.fill" + case "whatsapp": return "phone.bubble.left.fill" + default: return nil + } + } + + var body: some View { + HStack(spacing: 14) { + // Selection toggle — neutral white/gray, no accent color (per AGENTS.md: no purple). + Button(action: onToggle) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20, weight: .regular)) + .foregroundColor(isSelected ? OmiColors.textPrimary : OmiColors.textQuaternary) + } + .buttonStyle(.plain) + + // Rank badge (position by message count). + ZStack { + Circle() + .fill(OmiColors.backgroundTertiary) + .frame(width: 40, height: 40) + + Text("\(rank)") + .scaledFont(size: 15, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + } + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let platformIcon { + Image(systemName: platformIcon) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(OmiColors.textQuaternary) + } + Text(contact.displayName) + .scaledFont(size: 15, weight: .medium) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + } + + Text("\(contact.messageCount.formatted()) messages") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + + Spacer() + + if let errorMessage, !isTraining { + Text(errorMessage) + .scaledFont(size: 11, weight: .regular) + .foregroundColor(OmiColors.warning) + .lineLimit(2) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 180, alignment: .trailing) + } + + trailingControl + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + isHovered + ? OmiColors.backgroundTertiary.opacity(0.6) + : OmiColors.backgroundSecondary + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? OmiColors.border : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { onToggle() } + .onHover { isHovered = $0 } + } + + // MARK: - Trailing control (Train / Training… / Trained / Retry) + + @ViewBuilder + private var trailingControl: some View { + if isTraining { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.6) + .tint(.white) + Text("Training…") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + } + .frame(minWidth: 96) + } else if isTrained { + HStack(spacing: 8) { + if case .done = backtest { + // Once a backtest exists, the score badge replaces the "Trained" pill. + } else { + HStack(spacing: 5) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(OmiColors.textPrimary) + Text("Trained") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + } + } + + modePicker + + backtestControl + + // Live conversation + practice chat against the persona. + Button(action: onPreviewChat) { + Text("Chat") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.backgroundPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.textPrimary)) + } + .buttonStyle(.plain) + // Allow regenerating the persona from the latest history. + trainButton(title: "Retrain", filled: false) + } + } else { + trainButton(title: errorMessage == nil ? "Train" : "Retry", filled: true) + } + } + + // MARK: - Send-mode picker (Manual / Draft / Auto) + + private var modePicker: some View { + Menu { + ForEach(SendMode.allCases, id: \.self) { mode in + Button(action: { onSetMode(mode) }) { + if mode == sendMode { + Label(mode.fullLabel, systemImage: "checkmark") + } else { + Text(mode.fullLabel) + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: modeIcon) + .font(.system(size: 10, weight: .semibold)) + Text(sendMode.label) + .scaledFont(size: 12, weight: .semibold) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .semibold)) + } + // Force an explicit neutral/warning fill so the Menu never picks up the system + // accent color (which can be purple) — see AGENTS.md "Never use purple". + .foregroundStyle(modeTint) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8).stroke( + sendMode == .autonomous ? OmiColors.warning.opacity(0.6) : OmiColors.border, + lineWidth: 1)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .tint(modeTint) + .fixedSize() + .help("How the clone handles new messages from \(contact.displayName)") + } + + private var modeIcon: String { + switch sendMode { + case .manual: return "hand.point.up.left" + case .draftReview: return "tray.full" + case .autonomous: return "bolt.fill" + } + } + + /// Neutral by default, warning-amber only for Autonomous. Never accent/purple. + private var modeTint: Color { + sendMode == .autonomous ? OmiColors.warning : OmiColors.textSecondary + } + + // MARK: - Backtest control (Run Backtest / progress / score badge) + + @ViewBuilder + private var backtestControl: some View { + switch backtest { + case .running(let progress): + HStack(spacing: 8) { + ProgressView().scaleEffect(0.55).tint(.white) + VStack(alignment: .leading, spacing: 1) { + Text("Iteration \(progress.iteration)/\(progress.maxIterations)") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text(progress.subtitle) + .scaledFont(size: 10, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + } + .frame(minWidth: 128, alignment: .leading) + + case .done(let result): + Button(action: onShowBacktestDetail) { + HStack(spacing: 6) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 11, weight: .semibold)) + Text("Avg \(AICloneScoreFormat.pct(result.averageScore))") + .scaledFont(size: 13, weight: .semibold) + } + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + .help("\(result.iterationsRun) iteration\(result.iterationsRun == 1 ? "" : "s") • click for held-out pairs") + + case .failed(let message): + HStack(spacing: 6) { + Text(message) + .scaledFont(size: 11, weight: .regular) + .foregroundColor(OmiColors.warning) + .lineLimit(1) + .frame(maxWidth: 120) + backtestRunButton(title: "Retry") + } + + case nil: + backtestRunButton(title: "Run Backtest") + } + } + + private func backtestRunButton(title: String) -> some View { + Button(action: onRunBacktest) { + Text(title) + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + private func trainButton(title: String, filled: Bool) -> some View { + Button(action: onTrain) { + Text(title) + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(filled ? OmiColors.backgroundPrimary : OmiColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background( + Group { + if filled { + RoundedRectangle(cornerRadius: 8).fill(OmiColors.textPrimary) + } else { + RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1) + } + } + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Chat sheet (Live conversation + Practice) + +/// Identifies which trained contact the chat sheet is for. +private struct AICloneChatTarget: Identifiable { + let contact: ImportedContact + let persona: ContactPersona + var id: String { contact.id } +} + +/// Chat sheet with two tabs: **Live** — the real conversation with this contact (recent +/// history, new messages as they arrive, clone-suggested replies you can edit and send, +/// and a composer for your own messages) — and **Practice**, the original simulator where +/// you type as the contact and see how the clone would reply as you. +private struct AICloneChatSheet: View { + let contact: ImportedContact + let persona: ContactPersona + + @Environment(\.dismiss) private var dismiss + @State private var mode: Mode = .live + + enum Mode: String, CaseIterable { + case live = "Live" + case practice = "Practice" + } + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(OmiColors.border) + // Both tabs stay mounted so switching never drops transcript state or pauses the + // live poll; only the visible one receives interaction. + ZStack { + AICloneLiveChatView(contact: contact, persona: persona) + .opacity(mode == .live ? 1 : 0) + .allowsHitTesting(mode == .live) + AIClonePracticeChatView(contact: contact, persona: persona) + .opacity(mode == .practice ? 1 : 0) + .allowsHitTesting(mode == .practice) + } + } + .frame(width: 520, height: 660) + .background(OmiColors.backgroundPrimary) + .task { + // Build the retrieval index so replies get dynamic few-shot examples from the + // real history (no-op if already built for this contact). + if let messages = try? await AIClonePage.loadMessages(for: contact, limit: 1500) { + await AICloneRetrievalService.shared.ensureIndex( + contactId: contact.id, messages: messages) + } + } + } + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(contact.displayName) + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text( + mode == .live + ? "Your real conversation — the clone drafts replies you approve and send" + : "Type as \(contact.displayName) to see how the clone would reply as you" + ) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Picker("", selection: $mode) { + ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 170) + + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(OmiColors.textSecondary) + .padding(8) + .background(Circle().fill(OmiColors.backgroundSecondary)) + } + .buttonStyle(.plain) + } + .padding(16) + } +} + +// MARK: - Live conversation tab + +/// One rendered message in the live transcript. `pending` marks an optimistic local echo +/// (dispatched but not yet observed back in the platform's message store). +private struct AICloneLiveMessage: Identifiable, Equatable { + let id: String + let isFromMe: Bool + let text: String + let date: Date + var pending = false +} + +/// The real conversation with this contact: recent history, live-updating while the sheet +/// is open (3s poll of the platform store), clone-suggested replies for the latest incoming +/// message, and a composer to send your own messages for real. +private struct AICloneLiveChatView: View { + let contact: ImportedContact + let persona: ContactPersona + + private enum SuggestionState: Equatable { + case idle + case generating + case ready + case failed(String) + } + + @State private var messages: [AICloneLiveMessage] = [] + /// Keys of every message ever fetched (wider than what's displayed) so poll ticks + /// re-observing old history never duplicate bubbles. + @State private var seenKeys: Set = [] + @State private var isLoading = true + @State private var loadError: String? + + @State private var suggestionState: SuggestionState = .idle + @State private var suggestionText = "" + @State private var isSendingSuggestion = false + + @State private var draft = "" + @State private var isSendingOwn = false + @State private var sendError: String? + @FocusState private var inputFocused: Bool + + var body: some View { + VStack(spacing: 0) { + transcript + Divider().overlay(OmiColors.border) + suggestionSection + inputBar + } + .onAppear { + inputFocused = true + // The page's onChange may not have run yet when the sheet content first appears. + AICloneChatAutomation.shared.activeContactId = contact.id + publishAutomationSnapshot() + } + .onChange(of: messages.count) { publishAutomationSnapshot() } + .onChange(of: suggestionState) { publishAutomationSnapshot() } + .onChange(of: isLoading) { publishAutomationSnapshot() } + .onReceive(NotificationCenter.default.publisher(for: .aiCloneChatSuggestRequested)) { _ in + guard isPresented else { return } + suggest() + } + .task { await runLiveLoop() } + } + + /// Whether this view is the currently-presented chat sheet. False once dismissed, even + /// if macOS keeps the sheet host (and this view's subscriptions) cached for reuse. + private var isPresented: Bool { + AICloneChatAutomation.shared.activeContactId == contact.id + } + + /// Mirror the live tab's state into the automation mailbox so bridge harness actions can + /// verify the real UI headlessly (local bridge is non-prod only). + private func publishAutomationSnapshot() { + guard isPresented else { return } + let suggestionLabel: String + switch suggestionState { + case .idle: suggestionLabel = "idle" + case .generating: suggestionLabel = "generating" + case .ready: suggestionLabel = "ready" + case .failed(let message): suggestionLabel = "failed: \(message)" + } + AICloneChatAutomation.shared.liveSnapshot = [ + "open": "true", + "contactId": contact.id, + "isLoading": isLoading ? "true" : "false", + "loadError": loadError ?? "", + "messageCount": String(messages.count), + "lastMessages": messages.suffix(5) + .map { "\($0.isFromMe ? "me" : "them"): \($0.text)" }.joined(separator: "\n"), + "suggestionState": suggestionLabel, + "suggestionText": suggestionText, + ] + } + + // MARK: Transcript + + private var transcript: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 8) { + if isLoading { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.6).tint(.white) + Text("Loading conversation…") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + .padding(.top, 40) + } else if let loadError { + Text(loadError) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + .padding(.top, 40) + } else if messages.isEmpty { + Text("No messages with \(contact.displayName) yet.") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .padding(.top, 40) + } + + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 8) { + if showsTimestamp(at: index) { + Text(message.date.formatted(date: .abbreviated, time: .shortened)) + .scaledFont(size: 10, weight: .medium) + .foregroundColor(OmiColors.textQuaternary) + .padding(.top, 6) + } + liveBubble(message) + } + .id(message.id) + } + + if let sendError { + Text(sendError) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + } + .onChange(of: messages.count) { scrollToBottom(proxy) } + .onAppear { scrollToBottom(proxy, animated: false) } + } + } + + /// Show a timestamp above the first message and whenever >1h passed since the previous. + private func showsTimestamp(at index: Int) -> Bool { + guard index > 0 else { return true } + return messages[index].date.timeIntervalSince(messages[index - 1].date) > 3600 + } + + private func liveBubble(_ message: AICloneLiveMessage) -> some View { + HStack(alignment: .bottom, spacing: 6) { + if message.isFromMe { Spacer(minLength: 60) } + + Text(message.text) + .scaledFont(size: 14, weight: .regular) + .foregroundColor(message.isFromMe ? OmiColors.backgroundPrimary : OmiColors.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(message.isFromMe ? OmiColors.textPrimary : OmiColors.backgroundSecondary) + ) + .opacity(message.pending ? 0.6 : 1) + .textSelection(.enabled) + + if message.pending { + Text("Sending…") + .scaledFont(size: 10, weight: .regular) + .foregroundColor(OmiColors.textQuaternary) + } + + if !message.isFromMe { Spacer(minLength: 60) } + } + .frame(maxWidth: .infinity, alignment: message.isFromMe ? .trailing : .leading) + } + + private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool = true) { + guard let last = messages.last else { return } + if animated { + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } + } else { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + + // MARK: Suggestion section (the clone's draft for the latest incoming message) + + @ViewBuilder + private var suggestionSection: some View { + switch suggestionState { + case .idle: + HStack { + Button(action: suggest) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 11, weight: .semibold)) + Text("Suggest Reply") + .scaledFont(size: 12, weight: .semibold) + } + .foregroundColor(hasIncoming ? OmiColors.textPrimary : OmiColors.textQuaternary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + .disabled(!hasIncoming) + .help("Have the clone draft a reply to \(contact.displayName)'s latest message") + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 10) + + case .generating: + HStack(spacing: 8) { + ProgressView().scaleEffect(0.55).tint(.white) + Text("Clone is drafting a reply…") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 10) + + case .ready: + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(OmiColors.textTertiary) + Text("CLONE SUGGESTS — EDIT BEFORE SENDING") + .scaledFont(size: 9, weight: .semibold) + .foregroundColor(OmiColors.textQuaternary) + Spacer() + } + + TextField("Reply", text: $suggestionText, axis: .vertical) + .textFieldStyle(.plain) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1...5) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.backgroundTertiary)) + + HStack(spacing: 8) { + Spacer() + Button(action: { suggestionState = .idle }) { + Text("Dismiss") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + + Button(action: suggest) { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .semibold)) + Text("Redo") + .scaledFont(size: 12, weight: .semibold) + } + .foregroundColor(OmiColors.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + + Button(action: sendSuggestion) { + HStack(spacing: 4) { + if isSendingSuggestion { + ProgressView().scaleEffect(0.45).tint(OmiColors.backgroundPrimary) + } else { + Image(systemName: "paperplane.fill") + .font(.system(size: 10, weight: .semibold)) + } + Text("Send") + .scaledFont(size: 12, weight: .semibold) + } + .foregroundColor(OmiColors.backgroundPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).fill(OmiColors.textPrimary)) + } + .buttonStyle(.plain) + .disabled( + isSendingSuggestion + || suggestionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(OmiColors.backgroundSecondary) + ) + .padding(.horizontal, 16) + .padding(.top, 10) + + case .failed(let message): + HStack(spacing: 8) { + Text(message) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + .lineLimit(2) + Spacer() + Button(action: suggest) { + Text("Retry") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 10) + } + } + + private var hasIncoming: Bool { messages.contains { !$0.isFromMe } } + + // MARK: Composer (your own real message) + + private var inputBar: some View { + HStack(spacing: 10) { + TextField("Message \(contact.displayName)…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .scaledFont(size: 14, weight: .regular) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1...4) + .focused($inputFocused) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + .onSubmit { sendOwn() } + + Button(action: sendOwn) { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(OmiColors.backgroundPrimary) + .frame(width: 36, height: 36) + .background(Circle().fill(canSendOwn ? OmiColors.textPrimary : OmiColors.textQuaternary)) + } + .buttonStyle(.plain) + .disabled(!canSendOwn) + .help("Sends for real to \(contact.displayName)") + } + .padding(16) + } + + private var canSendOwn: Bool { + !isSendingOwn && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: Live loop (initial load + poll) + + private func runLiveLoop() async { + await loadInitial() + // Also stop when this sheet is no longer the presented one — .task cancellation is + // not guaranteed on macOS when the dismissed sheet host is cached for reuse. + while !Task.isCancelled, isPresented { + try? await Task.sleep(nanoseconds: 3_000_000_000) + await refresh() + } + } + + private func loadInitial() async { + do { + let history = try await AIClonePage.loadMessages(for: contact, limit: 200) + let ordered = history.sorted { $0.date < $1.date } + var loaded: [AICloneLiveMessage] = [] + for message in ordered { + guard let entry = register(message) else { continue } + loaded.append(entry) + } + // Track keys for the full fetch window but only render the recent tail. + messages = Array(loaded.suffix(60)) + isLoading = false + } catch { + loadError = error.localizedDescription + isLoading = false + } + } + + /// Poll tick: append any messages not seen yet. A from-me arrival that matches an + /// optimistic pending bubble replaces it (the real store echo has the authoritative date). + private func refresh() async { + guard let history = try? await AIClonePage.loadMessages(for: contact, limit: 50) else { + return + } + var appendedIncoming = false + for message in history.sorted(by: { $0.date < $1.date }) { + guard let entry = register(message) else { continue } + if entry.isFromMe, + let pendingIdx = messages.firstIndex(where: { $0.pending && $0.text == entry.text }) + { + messages.remove(at: pendingIdx) + } + messages.append(entry) + if !entry.isFromMe { appendedIncoming = true } + } + // A new incoming message while the sheet is open → auto-draft a suggestion, but never + // stomp a draft the user may already be editing. + if appendedIncoming, suggestionState == .idle { + suggest() + } + } + + /// Dedupe gate: returns a renderable entry only the first time a message is observed. + private func register(_ message: ImportedMessage) -> AICloneLiveMessage? { + let key = + "\(Int(message.date.timeIntervalSince1970))|\(message.isFromMe ? 1 : 0)|\(message.text)" + guard !seenKeys.contains(key) else { return nil } + seenKeys.insert(key) + return AICloneLiveMessage( + id: key, isFromMe: message.isFromMe, text: message.text, date: message.date) + } + + // MARK: Actions + + /// Ask the clone for a reply to the latest incoming burst, with the real conversation + /// tail as context. + private func suggest() { + guard suggestionState != .generating else { return } + let turns = messages.filter { !$0.pending }.map { (isFromMe: $0.isFromMe, text: $0.text) } + guard let burst = AICloneLiveChat.latestIncomingBurst(in: turns) else { return } + suggestionState = .generating + sendError = nil + Task { + do { + let reply = try await AIClonePersonaService.shared.respond( + as: persona, to: burst.incoming, context: burst.context) + suggestionText = reply + suggestionState = .ready + } catch { + suggestionState = .failed(error.localizedDescription) + } + } + } + + private func sendSuggestion() { + let text = suggestionText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSendingSuggestion else { return } + isSendingSuggestion = true + sendError = nil + Task { + do { + // Multi-bubble suggestions go out as separate messages, like a real burst. + try await AICloneSendModeService.shared.sendBubbles( + contactId: contact.id, displayName: contact.displayName, text: text, mode: .manual) + for bubble in AICloneReplyPresentation.bubbles(from: text) { + appendOptimistic(text: bubble) + } + suggestionText = "" + suggestionState = .idle + } catch { + sendError = error.localizedDescription + } + isSendingSuggestion = false + } + } + + private func sendOwn() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSendingOwn else { return } + isSendingOwn = true + sendError = nil + Task { + do { + try await AICloneSendModeService.shared.send( + contactId: contact.id, displayName: contact.displayName, text: text, mode: .manual) + appendOptimistic(text: text) + draft = "" + } catch { + sendError = error.localizedDescription + } + isSendingOwn = false + } + } + + /// Local echo shown immediately after a successful dispatch; the next poll tick swaps it + /// for the real store entry. + private func appendOptimistic(text: String) { + messages.append( + AICloneLiveMessage( + id: "local-\(UUID().uuidString)", isFromMe: true, text: text, date: Date(), + pending: true)) + } +} + +/// Pure helpers for the live chat, kept off the view for unit testing. +enum AICloneLiveChat { + /// The most recent run of consecutive incoming bubbles — joined newline-style like the + /// multi-bubble bursts `respond()` is trained on — plus up to 8 turns of preceding + /// context, shaped for `respond(as:to:context:)`. Nil when the thread has no incoming + /// messages at all. + static func latestIncomingBurst( + in turns: [(isFromMe: Bool, text: String)] + ) -> (incoming: String, context: [ConversationTurn])? { + guard let last = turns.lastIndex(where: { !$0.isFromMe }) else { return nil } + var start = last + while start > 0, !turns[start - 1].isFromMe { start -= 1 } + let incoming = turns[start...last].map(\.text).joined(separator: "\n") + let context = turns[.. = [] + /// Reply bubbles with a send in flight. + @State private var sendingMessageIds: Set = [] + @State private var sendError: String? + @FocusState private var inputFocused: Bool + + /// Whether the clone can actually send on this contact's platform (WhatsApp can't). + private var platformCanSend: Bool { AIClonePlatform.of(contactId: contact.id).canSend } + + var body: some View { + VStack(spacing: 0) { + transcript + Divider().overlay(OmiColors.border) + inputBar + } + } + + // MARK: Transcript + + private var transcript: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 10) { + if messages.isEmpty && !isResponding { + Text("Send a message to see the predicted reply.") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } + + ForEach(messages) { message in + bubble(for: message) + .id(message.id) + } + + if isResponding { + HStack { + typingBubble + Spacer(minLength: 60) + } + .id("typing") + } + + if let errorMessage { + Text(errorMessage) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let sendError { + Text(sendError) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + } + .onChange(of: messages.count) { scrollToBottom(proxy) } + .onChange(of: isResponding) { scrollToBottom(proxy) } + } + } + + @ViewBuilder + private func bubble(for message: AIClonePreviewMessage) -> some View { + let isReply = message.kind == .reply + VStack(alignment: isReply ? .trailing : .leading, spacing: 4) { + HStack { + if isReply { Spacer(minLength: 60) } + + Text(message.text) + .scaledFont(size: 14, weight: .regular) + .foregroundColor(isReply ? OmiColors.backgroundPrimary : OmiColors.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isReply ? OmiColors.textPrimary : OmiColors.backgroundSecondary) + ) + .textSelection(.enabled) + + if !isReply { Spacer(minLength: 60) } + } + + // Manual send: each predicted reply can be dispatched to the real contact. + if isReply && platformCanSend { + replySendControl(for: message) + } + } + } + + @ViewBuilder + private func replySendControl(for message: AIClonePreviewMessage) -> some View { + if sentMessageIds.contains(message.id) { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10, weight: .semibold)) + Text("Sent to \(contact.displayName)") + .scaledFont(size: 11, weight: .medium) + } + .foregroundColor(OmiColors.success) + .padding(.trailing, 4) + } else if sendingMessageIds.contains(message.id) { + HStack(spacing: 5) { + ProgressView().scaleEffect(0.5).tint(.white) + Text("Sending…") + .scaledFont(size: 11, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + } + .padding(.trailing, 4) + } else { + Button(action: { sendForReal(message) }) { + HStack(spacing: 4) { + Image(systemName: "paperplane.fill") + .font(.system(size: 9, weight: .semibold)) + Text("Send for Real") + .scaledFont(size: 11, weight: .semibold) + } + .foregroundColor(OmiColors.textSecondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 6).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.trailing, 4) + } + } + + private var typingBubble: some View { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.6).tint(.white) + Text("Predicting reply…") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + } + + // MARK: Input + + private var inputBar: some View { + HStack(spacing: 10) { + TextField("Message as \(contact.displayName)…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .scaledFont(size: 14, weight: .regular) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1...4) + .focused($inputFocused) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + .onSubmit { send() } + + Button(action: send) { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(OmiColors.backgroundPrimary) + .frame(width: 36, height: 36) + .background(Circle().fill(canSend ? OmiColors.textPrimary : OmiColors.textQuaternary)) + } + .buttonStyle(.plain) + .disabled(!canSend) + } + .padding(16) + } + + private var canSend: Bool { + !isResponding && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: Actions + + private func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isResponding else { return } + + // Carry the last few turns of this preview as context so the clone replies in flow. + let context = messages.suffix(4).map { + ConversationTurn(isFromMe: $0.kind == .reply, text: $0.text) + } + + messages.append(AIClonePreviewMessage(kind: .incoming, text: text)) + draft = "" + errorMessage = nil + isResponding = true + + Task { + do { + let reply = try await AIClonePersonaService.shared.respond( + as: persona, to: text, context: context) + // A burst reply comes back as newline-joined bubbles — render each as its own + // message bubble, exactly like the real person's multi-text bursts. + for bubble in AICloneReplyPresentation.bubbles(from: reply) { + messages.append(AIClonePreviewMessage(kind: .reply, text: bubble)) + } + } catch { + errorMessage = error.localizedDescription + } + isResponding = false + } + } + + /// Manually dispatch one predicted reply bubble to the real contact via the platform send + /// service. This is the only send path in Manual mode — the user explicitly taps it. + private func sendForReal(_ message: AIClonePreviewMessage) { + guard message.kind == .reply, !sendingMessageIds.contains(message.id), + !sentMessageIds.contains(message.id) + else { return } + sendingMessageIds.insert(message.id) + sendError = nil + Task { + do { + try await AICloneSendModeService.shared.send( + contactId: contact.id, displayName: contact.displayName, text: message.text, + mode: .manual) + sentMessageIds.insert(message.id) + } catch { + sendError = error.localizedDescription + } + sendingMessageIds.remove(message.id) + } + } + + private func scrollToBottom(_ proxy: ScrollViewProxy) { + withAnimation(.easeOut(duration: 0.2)) { + if isResponding { + proxy.scrollTo("typing", anchor: .bottom) + } else if let last = messages.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } +} + +/// Splits a clone reply (newline-joined bubbles) into the separate message bubbles the +/// preview transcript renders — one bubble per line, blanks dropped. +enum AICloneReplyPresentation { + static func bubbles(from reply: String) -> [String] { + reply.components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} + +// MARK: - Backtest UI models + +/// Per-row backtest state. +enum AICloneBacktestUIState { + case running(AICloneBacktestProgressUI) + case done(BacktestResult) + case failed(String) +} + +struct AICloneBacktestProgressUI { + let iteration: Int + let maxIterations: Int + let phase: String + let latestAverage: Double? + + /// e.g. "Backtesting" or "Refining · best 78%". + var subtitle: String { + if let latestAverage { + return "\(phase) · best \(AICloneScoreFormat.pct(latestAverage))" + } + return phase + } +} + +/// Identifies which contact's backtest results the detail sheet shows. +private struct AICloneBacktestDetail: Identifiable { + let contact: ImportedContact + let result: BacktestResult + var id: String { contact.id } +} + + +enum AICloneScoreFormat { + /// A cosine score in [-1, 1] rendered as a 0–100% match. + static func pct(_ score: Double) -> String { + "\(Int((max(0, min(1, score)) * 100).rounded()))%" + } + + static func color(_ score: Double) -> Color { + switch score { + case 0.85...: return OmiColors.success + case 0.65..<0.85: return OmiColors.textPrimary + default: return OmiColors.warning + } + } +} + +// MARK: - Backtest results sheet + +/// Shows the average score prominently and the held-out pairs so the user can eyeball +/// quality: their message / what the clone predicted / what the user actually said / score. +private struct AICloneBacktestSheet: View { + let contact: ImportedContact + let result: BacktestResult + + @Environment(\.dismiss) private var dismiss + + private var scoredPairs: [BacktestPair] { + result.pairs + .filter { $0.similarityScore != nil } + .sorted { ($0.similarityScore ?? 0) > ($1.similarityScore ?? 0) } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(OmiColors.border) + ScrollView { + LazyVStack(spacing: 12) { + ForEach(scoredPairs) { pair in + pairCard(pair) + } + if scoredPairs.isEmpty { + Text("No scored pairs — predictions or embeddings may have failed.") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .padding(.top, 40) + } + } + .padding(18) + } + } + .frame(width: 560, height: 640) + .background(OmiColors.backgroundPrimary) + } + + private var header: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Backtest — \(contact.displayName)") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text( + "\(result.iterationsRun) iteration\(result.iterationsRun == 1 ? "" : "s") · " + + "\(scoredPairs.count) held-out pairs · \(result.messageCountUsed) messages") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(AICloneScoreFormat.pct(result.averageScore)) + .scaledFont(size: 30, weight: .bold) + .foregroundColor(AICloneScoreFormat.color(result.averageScore)) + Text("avg match") + .scaledFont(size: 11, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + } + + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(OmiColors.textSecondary) + .padding(8) + .background(Circle().fill(OmiColors.backgroundSecondary)) + } + .buttonStyle(.plain) + } + .padding(18) + } + + private func pairCard(_ pair: BacktestPair) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(contact.displayName) + .scaledFont(size: 11, weight: .semibold) + .foregroundColor(OmiColors.textTertiary) + Spacer() + if let score = pair.similarityScore { + Text(AICloneScoreFormat.pct(score)) + .scaledFont(size: 12, weight: .bold) + .foregroundColor(AICloneScoreFormat.color(score)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule().fill(AICloneScoreFormat.color(score).opacity(0.14))) + } + } + + labeledLine(label: "They said", text: pair.contactMessage, tint: OmiColors.textSecondary) + labeledLine( + label: "Clone predicted", text: pair.predictedReply ?? "—", tint: OmiColors.textPrimary) + labeledLine(label: "You actually said", text: pair.actualReply, tint: OmiColors.success) + + if let reasoning = pair.judgeReasoning, !reasoning.isEmpty { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "gavel") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(OmiColors.textQuaternary) + Text(reasoning) + .scaledFont(size: 11, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .italic() + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous).fill(OmiColors.backgroundSecondary)) + } + + private func labeledLine(label: String, text: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .scaledFont(size: 9, weight: .semibold) + .foregroundColor(OmiColors.textQuaternary) + Text(text) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(tint) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Pending draft row (Approve / Edit / Reject) + +/// One Draft-Review suggestion: the incoming message, the clone's proposed reply (editable +/// in place), and Approve / Reject actions. Approving sends for real via the send service. +private struct AIClonePendingDraftRow: View { + let draft: AIClonePendingDraft + @ObservedObject private var sendMode = AICloneSendModeService.shared + @State private var editedText: String + @State private var isEditing = false + + init(draft: AIClonePendingDraft) { + self.draft = draft + _editedText = State(initialValue: draft.draftText) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(draft.contactDisplayName) + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1) + Spacer() + Text(draft.createdAt, style: .relative) + .scaledFont(size: 10, weight: .regular) + .foregroundColor(OmiColors.textQuaternary) + } + + Text("They said: \(draft.incomingText)") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + if isEditing { + TextField("Reply", text: $editedText, axis: .vertical) + .textFieldStyle(.plain) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1...5) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.backgroundTertiary)) + } else { + Text(editedText) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.backgroundTertiary)) + } + + HStack(spacing: 8) { + Spacer() + Button(action: { isEditing.toggle() }) { + Text(isEditing ? "Done" : "Edit") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + + Button(action: { sendMode.rejectDraft(draft) }) { + Text("Reject") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.warning) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).stroke(OmiColors.warning.opacity(0.5), lineWidth: 1)) + } + .buttonStyle(.plain) + + Button(action: { sendMode.approveDraft(draft, editedText: editedText) }) { + Text("Approve & Send") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.backgroundPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 7).fill(OmiColors.textPrimary)) + } + .buttonStyle(.plain) + .disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(OmiColors.backgroundPrimary)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(OmiColors.border, lineWidth: 1)) + } +} + +// MARK: - Recent Sent Messages log + +private struct AICloneSentLogSheet: View { + @ObservedObject private var sendMode = AICloneSendModeService.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Recent Sent Messages") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text("Everything the clone sent — manual, approved, or autonomous") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + Spacer() + if !sendMode.sentLog.isEmpty { + Button(action: { sendMode.clearSentLog() }) { + Text("Clear") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + } + .buttonStyle(.plain) + } + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(OmiColors.textSecondary) + .padding(8) + .background(Circle().fill(OmiColors.backgroundSecondary)) + } + .buttonStyle(.plain) + } + .padding(16) + Divider().overlay(OmiColors.border) + + if sendMode.sentLog.isEmpty { + VStack(spacing: 10) { + Image(systemName: "paperplane") + .font(.system(size: 30, weight: .regular)) + .foregroundColor(OmiColors.textQuaternary) + Text("Nothing sent yet") + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(OmiColors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(sendMode.sentLog) { entry in + sentRow(entry) + } + } + .padding(16) + } + } + } + .frame(width: 480, height: 560) + .background(OmiColors.backgroundPrimary) + } + + private func sentRow(_ entry: AICloneSentLogEntry) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(entry.contactDisplayName) + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .lineLimit(1) + modeTag(entry.mode) + Spacer() + Text(entry.timestamp, style: .relative) + .scaledFont(size: 10, weight: .regular) + .foregroundColor(OmiColors.textQuaternary) + } + Text(entry.text) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(OmiColors.backgroundSecondary)) + } + + private func modeTag(_ mode: SendMode) -> some View { + Text(mode.fullLabel) + .scaledFont(size: 9, weight: .semibold) + .foregroundColor(mode == .autonomous ? OmiColors.warning : OmiColors.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill( + (mode == .autonomous ? OmiColors.warning : OmiColors.textTertiary).opacity(0.14))) + } +} + +// MARK: - WhatsApp linking sheet (QR scan via Linked Devices) + +/// Drives the WhatsApp linking flow against the local Baileys sidecar: starts the sidecar, +/// shows the QR to scan from the phone (WhatsApp → Settings → Linked Devices → Link a +/// Device), polls until linked, then shows the linked number. Session persists on disk, so +/// this is one-time unless the user unlinks. +private struct WhatsAppLinkSheet: View { + @ObservedObject private var link = WhatsAppLinkModel.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(OmiColors.border) + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider().overlay(OmiColors.border) + footer + } + .frame(width: 420, height: 560) + .background(OmiColors.backgroundPrimary) + .task { + // Kick off (or resume) linking, then poll while the sheet is open. The poll drives + // QR refreshes (WhatsApp rotates codes every ~20s) and the linked transition. + _ = await WhatsAppSendService.shared.startLinking() + while !Task.isCancelled { + let state = await WhatsAppSendService.shared.refreshStatus() + if state.isLinked { + // Live listener can attach now that a linked session exists. + AICloneSendModeService.shared.startWhatsAppListenerIfLinked() + } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text("Link WhatsApp") + .scaledFont(size: 16, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text("Connects this Mac as a linked device, like WhatsApp Web") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(OmiColors.textSecondary) + .padding(8) + .background(Circle().fill(OmiColors.backgroundSecondary)) + } + .buttonStyle(.plain) + } + .padding(16) + } + + @ViewBuilder + private var content: some View { + switch link.state { + case .stopped, .starting: + statusStack(spinner: true, title: "Starting the WhatsApp connector…", detail: nil) + + case .connecting: + statusStack( + spinner: true, title: "Contacting WhatsApp…", + detail: "Generating a QR code (or resuming your saved session).") + + case .unlinked, .loggedOut: + VStack(spacing: 14) { + Image(systemName: "qrcode") + .font(.system(size: 34, weight: .regular)) + .foregroundColor(OmiColors.textQuaternary) + Text( + link.state == .loggedOut + ? "This device was unlinked from your phone." + : "Not linked yet.") + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + Button(action: { Task { await WhatsAppSendService.shared.startLinking() } }) { + Text("Generate QR Code") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.backgroundPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(OmiColors.textPrimary)) + } + .buttonStyle(.plain) + } + + case .waitingScan(let qrDataUrl): + VStack(spacing: 14) { + if let image = Self.image(fromDataUrl: qrDataUrl) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 240, height: 240) + .padding(10) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white)) + } else { + statusStack(spinner: true, title: "Preparing QR code…", detail: nil) + } + VStack(spacing: 4) { + Text("Scan with your phone") + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text("WhatsApp → Settings → Linked Devices → Link a Device") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + } + } + + case .linked(let phone): + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 36, weight: .regular)) + .foregroundColor(OmiColors.success) + Text("Linked as \(phone.isEmpty ? "your account" : "+\(phone)")") + .scaledFont(size: 15, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text("The clone can now send and receive WhatsApp messages on this Mac.") + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + Button(action: { Task { await WhatsAppSendService.shared.logout() } }) { + Text("Unlink") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(OmiColors.warning) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8).stroke( + OmiColors.warning.opacity(0.5), lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.top, 6) + } + + case .error(let message): + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30, weight: .regular)) + .foregroundColor(OmiColors.warning) + Text(message) + .scaledFont(size: 13, weight: .regular) + .foregroundColor(OmiColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + Button(action: { Task { await WhatsAppSendService.shared.startLinking() } }) { + Text("Try Again") + .scaledFont(size: 13, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).stroke(OmiColors.border, lineWidth: 1)) + } + .buttonStyle(.plain) + } + } + } + + private var footer: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.shield") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(OmiColors.textQuaternary) + .padding(.top, 1) + Text( + "WhatsApp has no official API for personal accounts — this uses an unofficial " + + "connection through Linked Devices. Automated messaging can put an account " + + "at risk of being flagged; keep automated replies conservative." + ) + .scaledFont(size: 11, weight: .regular) + .foregroundColor(OmiColors.textQuaternary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + } + + private func statusStack(spinner: Bool, title: String, detail: String?) -> some View { + VStack(spacing: 12) { + if spinner { + ProgressView().scaleEffect(1.1).tint(.white) + } + Text(title) + .scaledFont(size: 14, weight: .medium) + .foregroundColor(OmiColors.textSecondary) + if let detail { + Text(detail) + .scaledFont(size: 12, weight: .regular) + .foregroundColor(OmiColors.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + } + } + } + + /// Decode a `data:image/png;base64,…` URL (the sidecar's QR payload) into an NSImage. + static func image(fromDataUrl dataUrl: String) -> NSImage? { + guard let comma = dataUrl.firstIndex(of: ","), + let data = Data(base64Encoded: String(dataUrl[dataUrl.index(after: comma)...])) + else { return nil } + return NSImage(data: data) + } +} + +#Preview { + AIClonePage() +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 8f10e5171ec..2480c879a69 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -227,6 +227,8 @@ struct DashboardPage: View { @State private var conversationCount: Int? @State private var memoryCount: Int? @State private var taskCount: Int? + /// Number of contacts with a trained AI Clone persona (drives the AI Clone tile value). + @State private var trainedCloneCount: Int? @State private var isCaptureMonitoring = false @State private var isTogglingCapture = false @State private var isTogglingListening = false @@ -313,6 +315,7 @@ struct DashboardPage: View { syncCaptureState() Task { await loadScreenshotCount() } Task { await loadKnowledgeCounts() } + Task { await loadTrainedCloneCount() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in viewModel.refreshGoals() @@ -320,6 +323,7 @@ struct DashboardPage: View { syncCaptureState() Task { await loadScreenshotCount() } Task { await loadKnowledgeCounts() } + Task { await loadTrainedCloneCount() } } .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in syncCaptureState() @@ -496,10 +500,12 @@ struct DashboardPage: View { taskValue: taskMetricValue, memoryValue: memoryMetricValue, screenshotValue: screenshotMetricValue, + aiCloneValue: aiCloneMetricValue, onConversations: { navigate(to: .conversations) }, onTasks: { navigate(to: .tasks) }, onMemories: { navigate(to: .memories) }, - onScreenshots: { navigate(to: .rewind) } + onScreenshots: { navigate(to: .rewind) }, + onAIClone: { navigate(to: .aiClone) } ) } @@ -517,23 +523,6 @@ struct DashboardPage: View { .frame(height: 62, alignment: .bottomLeading) } - private var centerMemoryHeader: some View { - VStack(spacing: 2) { - Text("omi.") - .font(.system(size: 42, weight: .bold, design: .rounded)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .shadow(color: HomePalette.glow.opacity(0.42), radius: 20) - - Text("What omi knows") - .font(.system(size: 15, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.secondary) - .multilineTextAlignment(.center) - .lineLimit(1) - } - .frame(height: 62, alignment: .bottom) - } - private var sourceConstellation: some View { VStack(alignment: .leading, spacing: 12) { HomeAIChoiceButton(title: "Gmail", brand: .gmail, isConnected: isImportConnectorConnected("email")) { @@ -592,48 +581,6 @@ struct DashboardPage: View { } } - private var homeMetricsStrip: some View { - VStack(spacing: 8) { - homeMetricTopRow - homeMetricBottomRow - } - .frame(maxWidth: .infinity) - } - - private var homeMetricTopRow: some View { - HStack(spacing: 8) { - HomeCenterMetricTile( - title: "Conversations", - value: conversationMetricValue, - systemImage: "text.bubble.fill", - action: { navigate(to: .conversations) } - ) - HomeCenterMetricTile( - title: "Tasks", - value: taskMetricValue, - systemImage: "checklist", - action: { navigate(to: .tasks) } - ) - } - } - - private var homeMetricBottomRow: some View { - HStack(spacing: 8) { - HomeCenterMetricTile( - title: "Memories", - value: memoryMetricValue, - systemImage: "brain", - action: { navigate(to: .memories) } - ) - HomeCenterMetricTile( - title: "Screenshots", - value: screenshotMetricValue, - systemImage: "photo.on.rectangle.angled", - action: { navigate(to: .rewind) } - ) - } - } - private var conversationMetricValue: String { formattedCount(conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) } @@ -653,6 +600,10 @@ struct DashboardPage: View { screenshotCount.map(formattedCount) ?? "—" } + private var aiCloneMetricValue: String { + trainedCloneCount.map(formattedCount) ?? "—" + } + private func navigate(to item: SidebarNavItem) { selectedIndex = item.rawValue AnalyticsManager.shared.tabChanged(tabName: item.title) @@ -781,6 +732,13 @@ struct DashboardPage: View { } } + private func loadTrainedCloneCount() async { + let count = await AIClonePersonaService.shared.allPersonas().count + await MainActor.run { + trainedCloneCount = count + } + } + /// Load the true totals behind the "What omi knows" tiles. Conversations come /// from the server count endpoint (not stored locally); memories and tasks are /// counted from the synced local DB — the same totals the detail pages show. @@ -1224,201 +1182,76 @@ private struct HomeFlowParticle: View { } } -private struct HomePrimaryRouteButton: View { - let title: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - ConnectorBrandIcon(brand: brand, size: 20, cornerRadius: 6) - - Text(title) - .scaledFont(size: 13, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(minWidth: 118) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(HomePalette.green.opacity(isHovering ? 0.92 : 1)) - ) - .shadow(color: HomePalette.green.opacity(isHovering ? 0.22 : 0.12), radius: 12, y: 5) - .contentShape(.rect(cornerRadius: 11)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("Connect \(title)") - } -} - -private struct HomeInlineAction: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 7) { - icon - - Text(title) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: 9, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.72)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.3) : HomePalette.hairline.opacity(0.42), lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 18, cornerRadius: 5) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18, height: 18) - } - } -} - -private struct HomeSourceIconTile: View { +private struct HomeAIChoiceButton: View { let title: String let brand: ConnectorBrand? let systemImage: String? - let usesOmiDeviceImage: Bool + let usesOmiMark: Bool + let isPrimary: Bool let isConnected: Bool - let isBrowse: Bool let action: () -> Void @State private var isHovering = false - init( - title: String, - brand: ConnectorBrand, - isConnected: Bool = false, - action: @escaping () -> Void - ) { + init(title: String, brand: ConnectorBrand, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { self.title = title self.brand = brand self.systemImage = nil - self.usesOmiDeviceImage = false + self.usesOmiMark = false + self.isPrimary = isPrimary self.isConnected = isConnected - self.isBrowse = false self.action = action } - init( - title: String, - systemImage: String, - isBrowse: Bool = false, - action: @escaping () -> Void - ) { + init(title: String, systemImage: String, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { self.title = title self.brand = nil self.systemImage = systemImage - self.usesOmiDeviceImage = false - self.isConnected = false - self.isBrowse = isBrowse + self.usesOmiMark = false + self.isPrimary = isPrimary + self.isConnected = isConnected self.action = action } - init( - title: String, - usesOmiDeviceImage: Bool, - isConnected: Bool = false, - action: @escaping () -> Void - ) { + init(title: String, usesOmiMark: Bool, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { self.title = title self.brand = nil self.systemImage = nil - self.usesOmiDeviceImage = usesOmiDeviceImage + self.usesOmiMark = usesOmiMark + self.isPrimary = isPrimary self.isConnected = isConnected - self.isBrowse = false self.action = action } var body: some View { Button(action: action) { - VStack(spacing: 9) { - ZStack(alignment: .topTrailing) { - icon + HStack(spacing: 10) { + icon - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 9, height: 9) - .overlay(Circle().stroke(HomePalette.tile, lineWidth: 2)) - .offset(x: 2, y: -2) - } - } + Text(title) + .scaledFont(size: 14, weight: .semibold) + .foregroundStyle(HomePalette.ink) + .lineLimit(1) - HStack(spacing: 4) { - Text(title) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) + Spacer(minLength: 8) - if isBrowse { - Image(systemName: "chevron.right") - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(HomePalette.faint) - } + if isConnected { + Text("Connected") + .scaledFont(size: 11, weight: .medium) + .foregroundStyle(HomePalette.faint) } + + Image(systemName: "chevron.right") + .scaledFont(size: 10, weight: .bold) + .foregroundStyle(HomePalette.faint) } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(height: 48) .frame(maxWidth: .infinity) - .frame(height: 92) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? HomePalette.glow.opacity(0.58) : HomePalette.hairline.opacity(0.9), lineWidth: 1) - ) - .shadow(color: isHovering ? HomePalette.glow.opacity(0.16) : .clear, radius: 14) - .contentShape(.rect(cornerRadius: 17)) + .background(buttonBackground) + .overlay(buttonStroke) + .contentShape(.rect(cornerRadius: 15)) } .buttonStyle(.plain) .onHover { isHovering = $0 } @@ -1427,1185 +1260,221 @@ private struct HomeSourceIconTile: View { @ViewBuilder private var icon: some View { - if usesOmiDeviceImage { - HomeOmiDeviceIcon(size: 42, cornerRadius: 12) + if usesOmiMark { + HomeOmiMarkIcon(size: 24, cornerRadius: 7) } else if let brand { - ConnectorBrandIcon(brand: brand, size: 42, cornerRadius: 12) + ConnectorBrandIcon(brand: brand, size: 24, cornerRadius: 7) } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.05)) - Image(systemName: systemImage) - .scaledFont(size: 19, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 42, height: 42) + Image(systemName: systemImage) + .scaledFont(size: 14, weight: .bold) + .foregroundStyle(HomePalette.ink) + .frame(width: 24, height: 24) } } + + private var buttonBackground: some View { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) + } + + private var buttonStroke: some View { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke( + HomePalette.hairline.opacity(isHovering ? 1 : 0.9), + lineWidth: 1 + ) + } } -private struct HomeOmiDeviceIcon: View { +private struct HomeOmiMarkIcon: View { let size: CGFloat let cornerRadius: CGFloat + private static let markImage: NSImage? = { + guard let url = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png") else { + return nil + } + return NSImage(contentsOf: url) + }() + var body: some View { ZStack { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color.white.opacity(0.05)) + .fill(Color.white.opacity(0.08)) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.06), lineWidth: 1) ) - if let deviceImage = OmiDeviceImage.shared { - Image(nsImage: deviceImage) + if let image = Self.markImage { + Image(nsImage: image) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) - .padding(size * 0.16) + .padding(size * 0.18) } else { - Image(systemName: "wave.3.right.circle.fill") - .scaledFont(size: size * 0.45, weight: .semibold) - .foregroundStyle(HomePalette.secondary) + OmiDotRing() + .frame(width: size * 0.58, height: size * 0.58) } } .frame(width: size, height: size) } } -private struct HomeDataSourceCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let actionTitle: String - let isConnected: Bool - let action: () -> Void +private struct OmiDotRing: View { + var body: some View { + ZStack { + ForEach(0..<8, id: \.self) { index in + Circle() + .fill(HomePalette.ink) + .frame(width: 3.5, height: 3.5) + .offset(y: -6) + .rotationEffect(.degrees(Double(index) * 45)) + } + } + } +} - @State private var isHovering = false +private struct HomeCenterMemoryColumn: View { + let conversationValue: String + let taskValue: String + let memoryValue: String + let screenshotValue: String + let aiCloneValue: String + let onConversations: () -> Void + let onTasks: () -> Void + let onMemories: () -> Void + let onScreenshots: () -> Void + let onAIClone: () -> Void - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action + var body: some View { + content } - init( - title: String, - subtitle: String, - systemImage: String, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action + private var content: AnyView { + let stack = VStack(spacing: 18) { + header + metrics + } + // Group the title directly above the cards and center the unit in a + // column the same height as the side lists. + let columnHeight = CGFloat(422) + let framed = stack.frame(width: CGFloat(340), height: columnHeight, alignment: Alignment.center) + return AnyView(framed) } - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - icon - - VStack(alignment: .leading, spacing: 3) { - Text(title) - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) + private var header: AnyView { + AnyView(VStack(spacing: 2) { + Text("omi.") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundStyle(HomePalette.ink) + .lineLimit(1) + .shadow(color: HomePalette.glow.opacity(0.42), radius: 20) - Text(subtitle) - .scaledFont(size: 12, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 10) - - HStack(spacing: 5) { - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 5, height: 5) - } - - Text(actionTitle) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(isConnected ? HomePalette.green : HomePalette.secondary) - .lineLimit(1) - - if !isConnected && actionTitle == "Browse" { - Image(systemName: "chevron.right") - .scaledFont(size: 9, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - } - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, 13) - .padding(.vertical, 11) - .frame(height: 64) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? HomePalette.glow.opacity(0.5) : HomePalette.hairline.opacity(0.9), lineWidth: 1) - ) - .shadow(color: isHovering ? HomePalette.glow.opacity(0.12) : .clear, radius: 12) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle), \(actionTitle)") - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: 10) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.white.opacity(0.04)) - Image(systemName: systemImage) - .scaledFont(size: 17, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 36, height: 36) - } - } -} - -private struct HomeAIChoiceButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let usesOmiMark: Bool - let isPrimary: Bool - let isConnected: Bool - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init(title: String, systemImage: String, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init(title: String, usesOmiMark: Bool, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = nil - self.usesOmiMark = usesOmiMark - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - icon - - Text(title) - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Spacer(minLength: 8) - - if isConnected { - Text("Connected") - .scaledFont(size: 11, weight: .medium) - .foregroundStyle(HomePalette.faint) - } - - Image(systemName: "chevron.right") - .scaledFont(size: 10, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(buttonBackground) - .overlay(buttonStroke) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } - - @ViewBuilder - private var icon: some View { - if usesOmiMark { - HomeOmiMarkIcon(size: 24, cornerRadius: 7) - } else if let brand { - ConnectorBrandIcon(brand: brand, size: 24, cornerRadius: 7) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: 14, weight: .bold) - .foregroundStyle(HomePalette.ink) - .frame(width: 24, height: 24) - } - } - - private var buttonBackground: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - } - - private var buttonStroke: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke( - HomePalette.hairline.opacity(isHovering ? 1 : 0.9), - lineWidth: 1 - ) - } -} - -private struct HomeOmiMarkIcon: View { - let size: CGFloat - let cornerRadius: CGFloat - - private static let markImage: NSImage? = { - guard let url = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png") else { - return nil - } - return NSImage(contentsOf: url) - }() - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color.white.opacity(0.08)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Color.white.opacity(0.06), lineWidth: 1) - ) - - if let image = Self.markImage { - Image(nsImage: image) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .padding(size * 0.18) - } else { - OmiDotRing() - .frame(width: size * 0.58, height: size * 0.58) - } - } - .frame(width: size, height: size) - } -} - -private struct OmiDotRing: View { - var body: some View { - ZStack { - ForEach(0..<8, id: \.self) { index in - Circle() - .fill(HomePalette.ink) - .frame(width: 3.5, height: 3.5) - .offset(y: -6) - .rotationEffect(.degrees(Double(index) * 45)) - } - } - } -} - -private struct HomeOrbitButton: View { - let title: String - let brand: ConnectorBrand - let badge: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, badge: String? = nil, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.badge = badge - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(spacing: 6) { - ZStack(alignment: .topTrailing) { - ConnectorBrandIcon(brand: brand, size: 44, cornerRadius: 13) - .shadow(color: .black.opacity(isHovering ? 0.16 : 0.08), radius: 9, y: 4) - - if let badge { - Text(badge) - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(.white) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(Capsule(style: .continuous).fill(HomePalette.green)) - .offset(x: 8, y: -6) - } - } - - Text(title) - .scaledFont(size: 11, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(isHovering ? HomePalette.panel : Color.clear) - ) - .contentShape(.rect(cornerRadius: 16)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - -private struct HomeDestinationCapsule: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 11, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: 11, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) - } - .padding(11) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.82)) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.32) : HomePalette.hairline.opacity(0.45), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } -} - -private struct HomeCommandCard: View { - let onChatGPT: () -> Void - let onClaude: () -> Void - let onAskOmi: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Text("Connect Omi to ChatGPT, Claude, or ask Omi directly...") - .scaledFont(size: 15, weight: .regular) - .foregroundStyle(HomePalette.faint) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - - Button(action: onAskOmi) { - Image(systemName: "arrow.up.circle") - .scaledFont(size: 24, weight: .regular) - .foregroundStyle(HomePalette.faint) - } - .buttonStyle(.plain) - .help("Ask Omi") - } - .padding(.horizontal, 18) - .padding(.top, 18) - .padding(.bottom, 24) - - HStack(spacing: 9) { - Button(action: onChatGPT) { - HStack(spacing: 8) { - ConnectorBrandIcon(brand: .chatgpt, size: 22, cornerRadius: 6) - Text("Connect ChatGPT") - .scaledFont(size: 13, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .foregroundStyle(.white) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(HomePalette.green) - ) - } - .buttonStyle(.plain) - - Button(action: onClaude) { - HStack(spacing: 8) { - ConnectorBrandIcon(brand: .claude, size: 22, cornerRadius: 6) - Text("Claude") - .scaledFont(size: 13, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .foregroundStyle(HomePalette.secondary) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(HomePalette.tile) - ) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 12) - .padding(.bottom, 12) - } - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(HomePalette.panel) - .shadow(color: .black.opacity(0.10), radius: 16, y: 8) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(HomePalette.hairline.opacity(0.72), lineWidth: 1) - ) - .frame(maxWidth: 720) - } -} - -private struct HomeSourceTile: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top) { - iconView - Spacer() - statusView - } - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 11) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - } - .padding(10) - .frame(minHeight: 78, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.4) : HomePalette.hairline.opacity(0.3), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 9)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var iconView: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 28, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(HomePalette.panel) - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 28, height: 28) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: 11, weight: .bold) - .foregroundStyle(HomePalette.secondary) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: 11, weight: .bold) - .foregroundStyle(HomePalette.green) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: 11, weight: .bold) - .foregroundStyle(HomePalette.secondary) - } - } -} - -private struct HomeCenterMemoryColumn: View { - let conversationValue: String - let taskValue: String - let memoryValue: String - let screenshotValue: String - let onConversations: () -> Void - let onTasks: () -> Void - let onMemories: () -> Void - let onScreenshots: () -> Void - - var body: some View { - content - } - - private var content: AnyView { - let stack = VStack(spacing: 18) { - header - metrics - } - // Group the title directly above the cards and center the unit in a - // column the same height as the side lists. - let columnHeight = CGFloat(422) - let framed = stack.frame(width: CGFloat(340), height: columnHeight, alignment: Alignment.center) - return AnyView(framed) - } - - private var header: AnyView { - AnyView(VStack(spacing: 2) { - Text("omi.") - .font(.system(size: 42, weight: .bold, design: .rounded)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .shadow(color: HomePalette.glow.opacity(0.42), radius: 20) - - Text("What omi knows") - .font(.system(size: 15, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.secondary) - .multilineTextAlignment(.center) - .lineLimit(1) - } - .frame(height: 62, alignment: .bottom)) - } - - private var metrics: AnyView { - AnyView(VStack(spacing: 8) { - HStack(spacing: 8) { - HomeCenterMetricTile( - title: "Conversations", - value: conversationValue, - systemImage: "text.bubble.fill", - action: onConversations - ) - HomeCenterMetricTile( - title: "Tasks", - value: taskValue, - systemImage: "checklist", - action: onTasks - ) - } - - HStack(spacing: 8) { - HomeCenterMetricTile( - title: "Memories", - value: memoryValue, - systemImage: "brain", - action: onMemories - ) - HomeCenterMetricTile( - title: "Screenshots", - value: screenshotValue, - systemImage: "photo.on.rectangle.angled", - action: onScreenshots - ) - } - } - .frame(maxWidth: .infinity)) - } -} - -private struct HomeCenterMetricTile: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 6) { - HStack { - Image(systemName: systemImage) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(HomePalette.ink) - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: 9, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.flowPink : HomePalette.faint) - } - - Text(value) - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.75) - - Text(title) - .scaledFont(size: 11, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - .minimumScaleFactor(0.82) - } - .padding(10) - .frame(maxWidth: .infinity, minHeight: 82, maxHeight: 82, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.92)) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? HomePalette.flowPink.opacity(0.5) : HomePalette.hairline.opacity(0.82), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeMemoryMetricCard: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.055)) - - Image(systemName: systemImage) - .scaledFont(size: 15, weight: .semibold) - .foregroundStyle(HomePalette.ink) - } - .frame(width: 42, height: 42) - - VStack(alignment: .leading, spacing: 2) { - Text(value) - .font(.system(size: 21, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) - - Text(title) - .scaledFont(size: 12, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: 10, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.glow : HomePalette.faint) - } - .padding(.horizontal, 14) - .frame(height: 76) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? HomePalette.glow.opacity(0.56) : HomePalette.hairline.opacity(0.86), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 17)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeMetricPill: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 7) { - Image(systemName: systemImage) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - - Text(value) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.ink) - - Text(title) - .scaledFont(size: 12, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.34) : HomePalette.hairline.opacity(0.64), lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeGlassPanel: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(HomePalette.panel) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(HomePalette.hairline.opacity(0.8), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 14, y: 6) - } -} - -private struct HomeStageHeader: View { - let eyebrow: String - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - Text(eyebrow.uppercased()) - .scaledFont(size: 10, weight: .bold) - .foregroundStyle(HomePalette.green) - - Text(title) - .scaledFont(size: 18, weight: .semibold) - .foregroundStyle(HomePalette.ink) + Text("What omi knows") + .font(.system(size: 15, weight: .medium, design: .serif)) + .foregroundStyle(HomePalette.secondary) + .multilineTextAlignment(.center) .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 12) - .foregroundStyle(HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } -} - -private struct HomeBridgeChevron: View { - var body: some View { - VStack(spacing: 8) { - Rectangle() - .fill( - LinearGradient( - colors: [.clear, OmiColors.border.opacity(0.65), .clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 1, height: 150) - - Image(systemName: "chevron.right") - .scaledFont(size: 16, weight: .bold) - .foregroundStyle(OmiColors.textTertiary) - } - .frame(width: 22) - .accessibilityHidden(true) - } -} - -private struct HomeSourceRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - rowIcon - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 11) - .foregroundStyle(OmiColors.textTertiary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - statusView - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? Color.white.opacity(0.08) : Color.white.opacity(0.035)) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? OmiColors.success.opacity(0.28) : Color.white.opacity(0.06), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 32, cornerRadius: 8) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(OmiColors.backgroundPrimary.opacity(0.78)) - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(OmiColors.textSecondary) - } - .frame(width: 32, height: 32) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: 12, weight: .bold) - .foregroundStyle(OmiColors.success) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: 12, weight: .bold) - .foregroundStyle(OmiColors.success) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: 12, weight: .bold) - .foregroundStyle(OmiColors.success) - } - } -} - -private struct HomeDestinationRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let prominence: HomeDestinationProminence - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.prominence = prominence - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.prominence = prominence - self.action = action + } + .frame(height: 62, alignment: .bottom)) } - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - rowIcon - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(prominence == .primary ? HomePalette.ink : HomePalette.secondary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 11) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: 11, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) + private var metrics: AnyView { + // Five tiles as a 3 + 2 grid: the bottom pair is centered and pinned to + // the same width as the tiles above, so no tile ever sits alone or + // stretches wider than its siblings. + let spacing = CGFloat(8) + let tileWidth = (CGFloat(340) - spacing * 2) / 3 + return AnyView(VStack(spacing: spacing) { + HStack(spacing: spacing) { + HomeCenterMetricTile( + title: "Conversations", + value: conversationValue, + systemImage: "text.bubble.fill", + action: onConversations + ) + HomeCenterMetricTile( + title: "Tasks", + value: taskValue, + systemImage: "checklist", + action: onTasks + ) + HomeCenterMetricTile( + title: "Memories", + value: memoryValue, + systemImage: "brain", + action: onMemories + ) } - .padding(.horizontal, 10) - .padding(.vertical, 10) - .background(rowBackground) - .overlay(rowStroke) - .contentShape(.rect(cornerRadius: 14)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(HomePalette.tile) - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.secondary) + HStack(spacing: spacing) { + HomeCenterMetricTile( + title: "Screenshots", + value: screenshotValue, + systemImage: "photo.on.rectangle.angled", + action: onScreenshots + ) + .frame(width: tileWidth) + HomeCenterMetricTile( + title: "AI Clone", + value: aiCloneValue, + systemImage: "person.2.fill", + action: onAIClone + ) + .frame(width: tileWidth) } - .frame(width: 34, height: 34) } - } - - private var rowBackground: some View { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - : (isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - } - - private var rowStroke: some View { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.42 : 0.24) - : HomePalette.hairline.opacity(isHovering ? 0.7 : 0.4), - lineWidth: 1 - ) + .frame(maxWidth: .infinity)) } } -private struct HomeMetricTile: View { +private struct HomeCenterMetricTile: View { let title: String let value: String let systemImage: String - let accent: Color let action: () -> Void @State private var isHovering = false var body: some View { Button(action: action) { - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(accent) + .scaledFont(size: 12, weight: .semibold) + .foregroundStyle(HomePalette.ink) - Spacer() + Spacer(minLength: 8) Image(systemName: "arrow.up.right") - .scaledFont(size: 10, weight: .bold) - .foregroundStyle(isHovering ? accent : OmiColors.textQuaternary) + .scaledFont(size: 9, weight: .bold) + .foregroundStyle(isHovering ? HomePalette.flowPink : HomePalette.faint) } Text(value) - .scaledFont(size: 20, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) + .font(.system(size: 20, weight: .medium, design: .serif)) + .foregroundStyle(HomePalette.ink) .lineLimit(1) + .minimumScaleFactor(0.75) Text(title) .scaledFont(size: 11, weight: .medium) - .foregroundStyle(OmiColors.textTertiary) + .foregroundStyle(HomePalette.muted) .lineLimit(1) + .minimumScaleFactor(0.82) } - .padding(11) - .frame(minHeight: 86, alignment: .topLeading) + .padding(10) + .frame(maxWidth: .infinity, minHeight: 82, maxHeight: 82, alignment: .topLeading) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.white.opacity(isHovering ? 0.08 : 0.04)) + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.92)) ) .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : Color.white.opacity(0.07), lineWidth: 1) + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke(isHovering ? HomePalette.flowPink.opacity(0.5) : HomePalette.hairline.opacity(0.82), lineWidth: 1) ) - .contentShape(.rect(cornerRadius: 16)) + .contentShape(.rect(cornerRadius: 15)) } .buttonStyle(.plain) .onHover { isHovering = $0 } @@ -2613,23 +1482,6 @@ private struct HomeMetricTile: View { } } -private struct HomeSectionHeader: View { - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .scaledFont(size: 20, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) - - Text(subtitle) - .scaledFont(size: 12) - .foregroundStyle(OmiColors.textTertiary) - } - } -} - private enum HomeStatusState { case active case inactive @@ -2741,36 +1593,6 @@ private struct HomeStatusButton: View { } } -private struct HomeIconActionButton: View { - let title: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - Image(systemName: systemImage) - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.muted) - .frame(width: 34, height: 34) - .background( - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Circle() - .stroke(HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58), lineWidth: 1) - ) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - private struct HomeSettingsMenuButton: View { let onRefer: () -> Void let onDiscord: () -> Void @@ -2850,284 +1672,6 @@ private struct HomeSettingsMenuButton: View { } } -private struct HomeConnectorCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let actionTitle: String - let status: String? - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: 9) - - VStack(alignment: .leading, spacing: 3) { - Text(title) - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: 12) - .foregroundStyle(OmiColors.textTertiary) - .lineLimit(1) - } - - Spacer(minLength: 10) - - if let status { - HStack(spacing: 5) { - Image(systemName: "checkmark") - .scaledFont(size: 10, weight: .bold) - Text(status) - .scaledFont(size: 12, weight: .semibold) - } - .foregroundStyle(OmiColors.success) - .lineLimit(1) - } else { - HStack(spacing: 5) { - Image(systemName: "plus") - .scaledFont(size: 10, weight: .bold) - Text(actionTitle) - .scaledFont(size: 12, weight: .semibold) - } - .foregroundStyle(OmiColors.success) - .lineLimit(1) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(minHeight: 56) - .background(cardBackground) - .overlay(cardStroke) - .contentShape(.rect(cornerRadius: 12)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(status ?? actionTitle)") - } - - private var cardBackground: some View { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OmiColors.backgroundSecondary.opacity(isHovering ? 0.94 : 0.72)) - } - - private var cardStroke: some View { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke( - isHovering ? OmiColors.success.opacity(0.32) : OmiColors.border.opacity(0.42), - lineWidth: 1 - ) - } -} - -private struct HomeMoreAppsCard: View { - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(OmiColors.backgroundPrimary) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(OmiColors.border.opacity(0.55), lineWidth: 1) - ) - - Image(systemName: "square.grid.2x2.fill") - .scaledFont(size: 15, weight: .semibold) - .foregroundStyle(OmiColors.textSecondary) - } - .frame(width: 36, height: 36) - - VStack(alignment: .leading, spacing: 3) { - Text("Connect more") - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) - - Text("Browse all apps") - .scaledFont(size: 12) - .foregroundStyle(OmiColors.textTertiary) - } - - Spacer() - - Image(systemName: "chevron.right") - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(OmiColors.success) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(minHeight: 56) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OmiColors.backgroundSecondary.opacity(isHovering ? 0.94 : 0.72)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke( - isHovering ? OmiColors.success.opacity(0.32) : OmiColors.border.opacity(0.42), - lineWidth: 1 - ) - ) - .contentShape(.rect(cornerRadius: 12)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } -} - -private struct HomeFlowArrow: View { - var body: some View { - VStack(spacing: 5) { - Rectangle() - .fill(OmiColors.border.opacity(0.75)) - .frame(width: 1, height: 14) - - Image(systemName: "chevron.down") - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(OmiColors.textSecondary) - } - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } -} - -private struct HomeMetricCard: View { - let title: String - let value: String - let subtitle: String - let systemImage: String - let accent: Color - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 11) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(accent.opacity(0.16)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(accent.opacity(0.28), lineWidth: 1) - ) - - Image(systemName: systemImage) - .scaledFont(size: 16, weight: .semibold) - .foregroundStyle(accent) - } - .frame(width: 38, height: 38) - - VStack(alignment: .leading, spacing: 3) { - Text(value) - .scaledFont(size: 20, weight: .semibold) - .foregroundStyle(OmiColors.textPrimary) - .lineLimit(1) - - Text(title) - .scaledFont(size: 13, weight: .medium) - .foregroundStyle(OmiColors.textTertiary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(isHovering ? accent : OmiColors.textQuaternary) - } - .padding(12) - .frame(minHeight: 64) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(OmiColors.backgroundSecondary.opacity(isHovering ? 0.96 : 0.78)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : OmiColors.border.opacity(0.44), lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 14)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value), \(subtitle)") - } -} - -private struct HomeAIButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - if let brand { - ConnectorBrandIcon(brand: brand, size: 26, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(OmiColors.backgroundTertiary) - Image(systemName: systemImage) - .scaledFont(size: 12, weight: .semibold) - .foregroundStyle(OmiColors.textSecondary) - } - .frame(width: 26, height: 26) - } - - Text(title) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(OmiColors.textSecondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: 10, weight: .bold) - .foregroundStyle(isHovering ? OmiColors.success : OmiColors.textQuaternary) - } - .padding(.leading, 9) - .padding(.trailing, 12) - .padding(.vertical, 7) - .background( - Capsule(style: .continuous) - .fill(OmiColors.backgroundSecondary.opacity(isHovering ? 0.96 : 0.76)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? OmiColors.success.opacity(0.32) : OmiColors.border.opacity(0.42), lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - #Preview { DashboardPage( viewModel: DashboardViewModel(), diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Advanced.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Advanced.swift index 857954bd62c..cb219459469 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Advanced.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Advanced.swift @@ -23,6 +23,8 @@ extension SettingsContentView { aiSetupSubsection advancedCategoryHeader(title: "Profile & Stats", icon: "brain") profileAndStatsSubsection + advancedCategoryHeader(title: "Tasks", icon: "checklist") + messageTaskContactsSubsection advancedCategoryHeader(title: "Reset Onboarding", icon: "arrow.counterclockwise") resetOnboardingSubsection advancedCategoryHeader(title: "Goals", icon: "target") @@ -39,6 +41,60 @@ extension SettingsContentView { } } + // MARK: - Tasks: per-person blocklist for message-based task capture + + /// Lets the user stop specific people's messages from ever being turned into tasks. + /// Backed by `TaskAssistantSettings.blockedContactIds`; the contact list comes from the + /// AI Clone's known/trained contacts. + var messageTaskContactsSubsection: some View { + settingsCard(settingId: "advanced.tasks.blockedpeople") { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Blocked People") + .scaledFont(size: 15, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + Text( + "Messages from these people won't be turned into tasks. Use this for anyone whose texts you don't want tracked." + ) + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + } + + if taskKnownContacts.isEmpty { + Text("No message contacts yet — train the AI Clone on a conversation first.") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + } else { + VStack(spacing: 4) { + ForEach(taskKnownContacts, id: \.id) { contact in + HStack(spacing: 12) { + Text(contact.displayName) + .scaledFont(size: 13) + .foregroundColor(OmiColors.textPrimary) + Spacer() + Toggle( + "", + isOn: Binding( + get: { taskBlockedContactIds.contains(contact.id) }, + set: { isBlocked in + TaskAssistantSettings.shared.setContactBlocked( + isBlocked, contactId: contact.id) + taskBlockedContactIds = TaskAssistantSettings.shared.blockedContactIds + } + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .padding(.vertical, 4) + } + } + } + } + } + } + // MARK: - Dev Tools Subsection var devToolsSubsection: some View { @@ -187,6 +243,10 @@ extension SettingsContentView { } } + settingsCard(settingId: "aichat.hermes") { + HermesConnectionCardContent() + } + settingsCard(settingId: "aichat.workspace") { VStack(alignment: .leading, spacing: 12) { HStack { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+ConnectedAgents.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+ConnectedAgents.swift new file mode 100644 index 00000000000..a0f02d17278 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+ConnectedAgents.swift @@ -0,0 +1,120 @@ +import SwiftUI + +/// Settings card for connecting Hermes to Nous Portal. Hermes only supports +/// interactive device-code OAuth (no API keys), so the card drives +/// `HermesConnectService`: it opens the verification page in the browser and +/// waits for the CLI to confirm approval. +struct HermesConnectionCardContent: View { + @ObservedObject private var service = HermesConnectService.shared + @State private var hermesInstalled = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "link") + .scaledFont(size: 16) + .foregroundColor(OmiColors.textTertiary) + + Text("Hermes") + .scaledFont(size: 15, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + + Spacer() + + trailingControl + } + + statusDetail + } + .onAppear { + hermesInstalled = LocalAgentProviderDetector.executablePath(for: .hermes) != nil + service.refreshConnectionState() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("settings-hermes-connection") + } + + @ViewBuilder + private var trailingControl: some View { + switch service.phase { + case .connected: + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .scaledFont(size: 12) + Text("Connected") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textSecondary) + } + case .starting, .waitingForApproval: + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Button("Cancel") { + service.cancel() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + case .idle, .failed: + Button(connectButtonTitle) { + service.connect() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!hermesInstalled) + .accessibilityIdentifier("settings-hermes-connect") + } + } + + private var connectButtonTitle: String { + if case .failed = service.phase { return "Retry sign-in" } + return "Connect Hermes" + } + + @ViewBuilder + private var statusDetail: some View { + switch service.phase { + case .idle: + if hermesInstalled { + Text("Sign in to Nous to run tasks with Hermes. Omi opens the sign-in page in your browser and waits for approval — no API key needed (Hermes doesn't use them).") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + } else { + Text("Hermes isn't installed on this Mac. Ask Omi to \"use Hermes\" to get the installer, then connect it here.") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + } + case .starting: + Text("Starting sign-in…") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + case .waitingForApproval(_, let userCode): + VStack(alignment: .leading, spacing: 8) { + Text("Waiting for approval in your browser… Approve the sign-in on the Nous page that just opened.") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + if let userCode, !userCode.isEmpty { + HStack(spacing: 8) { + Text("If the page asks for a code:") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + Text(userCode) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(OmiColors.textPrimary) + .textSelection(.enabled) + } + } + } + case .connected: + Text("Hermes is signed in to Nous. Ask Omi to \"use Hermes\" for any task.") + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + case .failed(let message): + Text(message) + .scaledFont(size: 12) + .foregroundColor(.red) + .textSelection(.enabled) + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift index cd475e8a52e..4765eacab03 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift @@ -156,6 +156,9 @@ struct SettingsContentView: View { @State var taskAllowedApps: Set @State var taskBrowserKeywords: [String] @State var isRescoringTasks = false + // Per-person blocklist for message-based task capture (dad's "get groceries" → task). + @State var taskBlockedContactIds: Set + @State var taskKnownContacts: [(id: String, displayName: String)] = [] // Advice Assistant states @State var insightEnabled: Bool @@ -428,6 +431,9 @@ struct SettingsContentView: View { initialValue: TaskAssistantSettings.shared.notificationsEnabled) _taskAllowedApps = State(initialValue: TaskAssistantSettings.shared.allowedApps) _taskBrowserKeywords = State(initialValue: TaskAssistantSettings.shared.browserKeywords) + _taskBlockedContactIds = State(initialValue: TaskAssistantSettings.shared.blockedContactIds) + _taskKnownContacts = State( + initialValue: AICloneSendModeService.shared.knownContactsForSettings()) _insightEnabled = State(initialValue: InsightAssistantSettings.shared.isEnabled) _insightExtractionInterval = State( initialValue: InsightAssistantSettings.shared.extractionInterval) diff --git a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift b/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift index 8b6c38efa40..078e8c81239 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift @@ -13,6 +13,7 @@ enum SidebarNavItem: Int, CaseIterable { case apps = 8 case settings = 9 case permissions = 10 + case aiClone = 11 case help = 12 var title: String { @@ -28,6 +29,7 @@ enum SidebarNavItem: Int, CaseIterable { case .apps: return "Apps" case .settings: return "Settings" case .permissions: return "Permissions" + case .aiClone: return "AI Clone" case .help: return "Help from Founder" } } @@ -45,6 +47,7 @@ enum SidebarNavItem: Int, CaseIterable { case .apps: return "puzzlepiece.fill" case .settings: return "gearshape.fill" case .permissions: return "exclamationmark.triangle.fill" + case .aiClone: return "person.2.fill" case .help: return "bubble.left.fill" } } @@ -64,7 +67,7 @@ enum SidebarNavItem: Int, CaseIterable { /// Items shown in the main navigation (top section) static var mainItems: [SidebarNavItem] { - [.dashboard, .conversations, .memories, .tasks, .rewind, .apps] + [.dashboard, .conversations, .memories, .tasks, .rewind, .apps, .aiClone] } } diff --git a/desktop/macos/Desktop/Sources/ModelQoS.swift b/desktop/macos/Desktop/Sources/ModelQoS.swift index 973c8683f7d..5f97dd4ed34 100644 --- a/desktop/macos/Desktop/Sources/ModelQoS.swift +++ b/desktop/macos/Desktop/Sources/ModelQoS.swift @@ -42,6 +42,11 @@ struct ModelQoS { /// Synthesis extraction tasks (calendar, gmail, notes, memory import) static var synthesis: String { "claude-haiku-4-5-20251001" } + /// AI Clone voice work (persona synthesis, reply generation, critic). + /// Accuracy-first: imitating a specific person's voice is the whole product, + /// so this stays on Sonnet even in the cost-optimized tier. + static var cloneVoice: String { "claude-sonnet-4-6" } + /// ChatLab test queries static var chatLabQuery: String { "claude-sonnet-4-20250514" } diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index 8bac62caa25..e1bee689ed2 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -260,6 +260,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Refresh the "Auto" realtime-voice model pick from Artificial Analysis (daily, cached). AutoModelSelector.shared.refreshIfStale() + // AI Clone: rebuild send-mode routing from persisted personas and start the message + // listeners if any contact is in Draft-Review/Autonomous. App-level on purpose — the + // clone must keep working when the AI Clone page isn't open. + AICloneSendModeService.shared.bootstrapAtLaunch() + // After a Sparkle update, show a small "what's new" card in the corner of the // main window once. Delayed so the window/overlay exist to render it. if restoreMainWindowAfterUpdateRelaunch != false { @@ -1289,6 +1294,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Stop transcription retry service TranscriptionRetryService.shared.stop() + // Terminate the WhatsApp sidecar (its stdin tether would catch this anyway). + WhatsAppSendService.terminateSidecarOnQuit() + // Stop recurring task scheduler RecurringTaskScheduler.shared.stop() diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatState.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatState.swift index 548ed9eedcc..ffb14512a19 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatState.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatState.swift @@ -285,6 +285,7 @@ class TaskChatState: ObservableObject { cwd: workspacePath.isEmpty ? nil : workspacePath, mode: chatMode.rawValue, resume: legacyAcpSessionId, + allowAdapterAutoSelection: true, onTextDelta: textDeltaHandler, onToolCall: toolCallHandler, onToolActivity: toolActivityHandler, @@ -303,11 +304,14 @@ class TaskChatState: ObservableObject { // are not interchangeable — storing them would pollute the resume // field and cause a different backend to attempt resuming the // wrong session after an adapter switch. - let supportsLegacyResume = (currentHarness == "acp" || currentHarness == "piMono") + let actualAdapterId = + queryResult.adapterId + ?? currentHarness.flatMap { AgentRuntimeProcess.adapterId(forHarnessMode: $0) } + let supportsLegacyResume = (actualAdapterId == "acp" || actualAdapterId == "pi-mono") if supportsLegacyResume { legacyAcpSessionId = adapterSessionId } else if legacyAcpSessionId != nil { - log("TaskChatState[\(taskId)]: not persisting adapter ID \(adapterSessionId) for non-legacy harness \(currentHarness ?? "?")") + log("TaskChatState[\(taskId)]: not persisting adapter ID \(adapterSessionId) for adapter \(actualAdapterId ?? "?")") legacyAcpSessionId = nil } } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/CommitmentExtractionService.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/CommitmentExtractionService.swift new file mode 100644 index 00000000000..a8ce5ac1d0a --- /dev/null +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/CommitmentExtractionService.swift @@ -0,0 +1,483 @@ +import Foundation + +/// One promise the user made to a contact, pulled verbatim from imported message history. +/// `commitmentText` is a verb-first task title naming the contact; `context` is the real +/// surrounding line it came from (never invented); `saidOn` is when it was said (best-effort). +struct ExtractedCommitment: Sendable, Hashable { + /// Verb-first task title, e.g. "Send Priya the signed lease by Friday". + let commitmentText: String + /// When the user made the commitment, if the model could resolve it. Best-effort. + let saidOn: Date? + /// The real surrounding message the commitment came from, kept for reference on the task. + let context: String + /// Model confidence 0.0–1.0 that this is a genuine, still-open commitment. + let confidence: Double +} + +/// Result of running a commitment scan on one contact: what was found, and how those +/// findings turned into tasks (some may be skipped as duplicates of already-tracked tasks). +struct CommitmentScanOutcome: Sendable { + let found: [ExtractedCommitment] + let created: Int + let duplicatesSkipped: Int +} + +enum CommitmentExtractionError: LocalizedError { + case notEnoughMessages + case llmFailed(String) + + var errorDescription: String? { + switch self { + case .notEnoughMessages: + return "Not enough message history with this contact to scan for commitments." + case .llmFailed(let detail): + return detail.isEmpty ? "Commitment scan failed. Please try again." : detail + } + } +} + +/// Scans imported message history (iMessage / Telegram / WhatsApp) for things the user +/// *promised* a contact and hasn't visibly followed through on, and turns each into a real +/// Task via the exact same staged-task pipeline the screen-based `TaskAssistant` uses +/// (`StagedTaskStorage` + `APIClient.createStagedTask` + `TaskPromotionService`). Read-only +/// analysis: it never sends messages and never touches AI-Clone send-mode behavior. +actor CommitmentExtractionService { + static let shared = CommitmentExtractionService() + + /// Bound the transcript window sent to the model to control cost/latency. + private let maxMessagesScanned = 200 + /// Below this the model's guess is too unreliable to nag the user about. + private let minConfidence = 0.6 + + private init() {} + + // MARK: - Public API + + /// Identify explicit, still-open commitments the user (isFromMe) made to `contact`. + /// Sends a bounded recent window through one LLM call; returns only findings whose + /// confidence clears `minConfidence`. Does not create tasks — see `scanAndCreateTasks`. + func scanForCommitments( + contact: ImportedContact, messages: [ImportedMessage] + ) async throws -> [ExtractedCommitment] { + guard messages.count >= 4 else { throw CommitmentExtractionError.notEnoughMessages } + + // Readers hand back newest-first; take the most recent window and render it + // chronologically so the model can see whether a promise was resolved later. + let window = Array(messages.prefix(maxMessagesScanned)).reversed() + let transcript = Self.formatTranscript(window, contact: contact) + + let prompt = Self.buildPrompt(transcript: transcript, contact: contact) + let system = + "You analyze a person's real text-message history with one contact and extract the " + + "still-open obligations the user owes that contact — both promises the user made and " + + "unfulfilled requests/reminders the contact sent for something the user owes. You are " + + "conservative and never invent obligations. Output only valid JSON." + + let responseText = try await runLLM(prompt: prompt, system: system, label: "scan") + let parsed = Self.parseCommitments(from: responseText, contact: contact) + let filtered = parsed.filter { $0.confidence >= minConfidence } + log( + "Commitment: \(contact.platform)/\(contact.displayName) — model returned \(parsed.count), " + + "\(filtered.count) above \(minConfidence) confidence") + return filtered + } + + /// Full pipeline: scan, then create a real staged Task for each fresh commitment (reusing + /// the app's existing task-creation mechanism) and kick promotion so they surface on the + /// Tasks page. Duplicates of already-tracked tasks are skipped via `isDuplicate`. + func scanAndCreateTasks( + contact: ImportedContact, messages: [ImportedMessage] + ) async throws -> CommitmentScanOutcome { + let commitments = try await scanForCommitments(contact: contact, messages: messages) + guard !commitments.isEmpty else { + return CommitmentScanOutcome(found: [], created: 0, duplicatesSkipped: 0) + } + + // One snapshot of what's already tracked (staged + active/completed action items) so a + // re-scan of the same contact never re-creates a task for the same commitment. + let existing = await existingTaskTitles() + + // Score new commitments just below existing prioritized tasks (most-confident first) so + // they're eligible for promotion into action_items — the same relevance-score mechanism + // the screen pipeline uses — without jumping ahead of the user's established priorities. + var nextScore = await nextRelevanceScore() + let ordered = commitments.sorted { $0.confidence > $1.confidence } + + var created = 0 + var duplicates = 0 + var createdThisRun: Set = [] + + for commitment in ordered { + let key = Self.normalizedTitle(commitment.commitmentText) + if key.isEmpty { continue } + if existing.contains(key) || createdThisRun.contains(key) { + duplicates += 1 + log("Commitment: skipping duplicate — \"\(commitment.commitmentText)\"") + continue + } + createdThisRun.insert(key) + if await createTask(from: commitment, contact: contact, relevanceScore: nextScore) { + created += 1 + nextScore += 1 + } + } + + if created > 0 { + // Same fast-path promotion the screen pipeline uses so the new tasks land on the + // Tasks page (and fire their notification) within seconds, not on the next timer. + await TaskPromotionService.shared.promoteIfNeeded() + } + + log( + "Commitment: \(contact.displayName) — created \(created), skipped \(duplicates) duplicates") + return CommitmentScanOutcome( + found: commitments, created: created, duplicatesSkipped: duplicates) + } + + // MARK: - Task creation (reuses the existing staged-task pipeline) + + /// Create one real Task from a commitment using the SAME mechanism `TaskAssistant` uses: + /// insert into `staged_tasks` locally, sync to the backend via `APIClient.createStagedTask`, + /// and mark the local row synced. Tagged `commitment_tracking` + platform so these are + /// visually distinct from screen-extracted tasks. Returns whether it was created. + private func createTask( + from commitment: ExtractedCommitment, contact: ImportedContact, relevanceScore: Int? + ) async -> Bool { + let tags = ["commitment_tracking", contact.platform] + let sourceApp = Self.platformLabel(contact.platform) + let noteContext = commitment.context.trimmingCharacters(in: .whitespacesAndNewlines) + let saidOnStr = commitment.saidOn.map { Self.dayFormatter.string(from: $0) } + + var metadata: [String: Any] = [ + "source_app": sourceApp, + "confidence": commitment.confidence, + "tags": tags, + "category": "commitment_tracking", + "source_category": "direct_request", + "source_subcategory": "commitment", + "contact_name": contact.displayName, + "contact_id": contact.id, + "platform": contact.platform, + ] + if !noteContext.isEmpty { metadata["reasoning"] = "Promised in chat: \"\(noteContext)\"" } + if let saidOnStr { metadata["said_on"] = saidOnStr } + + // Human-readable note describing where the commitment came from (shown on the task). + var noteParts = ["Commitment to \(contact.displayName) via \(sourceApp)"] + if let saidOnStr { noteParts.append("said \(saidOnStr)") } + if !noteContext.isEmpty { noteParts.append("— \"\(noteContext)\"") } + let contextSummary = noteParts.joined(separator: " ") + + let tagsJson = (try? JSONEncoder().encode(tags)).flatMap { String(data: $0, encoding: .utf8) } + let metadataJson = (try? JSONSerialization.data(withJSONObject: metadata)).flatMap { + String(data: $0, encoding: .utf8) + } + + // 1. Local staged_tasks row (identical shape to TaskAssistant.saveTaskToSQLite). + let record = StagedTaskRecord( + backendSynced: false, + description: commitment.commitmentText, + source: "commitment", + priority: TaskPriority.medium.rawValue, + category: "commitment_tracking", + tagsJson: tagsJson, + confidence: commitment.confidence, + sourceApp: sourceApp, + contextSummary: contextSummary, + metadataJson: metadataJson, + relevanceScore: relevanceScore, + scoredAt: relevanceScore != nil ? Date() : nil + ) + + let localId: Int64? + do { + // insertWithScoreShift keeps existing scores consistent when we slot a new task in — + // the same call the screen pipeline makes for a scored task. + let inserted = + relevanceScore != nil + ? try await StagedTaskStorage.shared.insertWithScoreShift(record) + : try await StagedTaskStorage.shared.insertLocalStagedTask(record) + localId = inserted.id + } catch { + logError("Commitment: failed to save staged task locally", error: error) + localId = nil + } + + // 2. Backend staged task (same call the screen pipeline makes), then mark synced. + do { + let response = try await APIClient.shared.createStagedTask( + description: commitment.commitmentText, + source: "commitment", + priority: TaskPriority.medium.rawValue, + category: "commitment_tracking", + metadata: metadata, + relevanceScore: relevanceScore + ) + if let localId { + try? await StagedTaskStorage.shared.markSynced(id: localId, backendId: response.id) + } + log("Commitment: created task \"\(commitment.commitmentText)\" (backend \(response.id))") + return true + } catch { + logError("Commitment: failed to sync task to backend", error: error) + // Local row still exists — count it only if the local insert succeeded so the user + // sees an accurate "created N" and the promotion loop can still pick it up on sync. + return localId != nil + } + } + + /// The relevance score to give the first new commitment: one past the largest score already + /// in use across action_items and scored staged tasks, so commitments slot in just below + /// the user's existing prioritized work (1 = most important; higher = less). Defaults to a + /// mid value when nothing is scored yet so they still promote. + private func nextRelevanceScore() async -> Int { + var maxScore = 0 + if let range = try? await ActionItemStorage.shared.getRelevanceScoreRange() { + maxScore = max(maxScore, range.max) + } + if let scoredStaged = try? await StagedTaskStorage.shared.getScoredStagedTasks(limit: 500) { + for task in scoredStaged { + if let score = task.relevanceScore { maxScore = max(maxScore, score) } + } + } + return maxScore + 1 + } + + // MARK: - Dedup + + /// Normalized titles of everything already tracked — staged tasks plus active and recently + /// completed action items — so a re-scan can skip commitments that are already tasks. + private func existingTaskTitles() async -> Set { + var titles: Set = [] + + if let staged = try? await StagedTaskStorage.shared.getAllStagedTasks(limit: 2000) { + for task in staged { titles.insert(Self.normalizedTitle(task.description)) } + } + if let active = try? await ActionItemStorage.shared.getRecentActiveTasks(limit: 500) { + for task in active { titles.insert(Self.normalizedTitle(task.description)) } + } + if let completed = try? await ActionItemStorage.shared.getRecentCompletedTasks(limit: 200) { + for task in completed { titles.insert(Self.normalizedTitle(task.description)) } + } + + titles.remove("") + return titles + } + + /// Lowercased, alphanumerics+spaces only, whitespace collapsed. A re-scan produces the + /// same commitment text → same normalized key → recognized as a duplicate. + static func normalizedTitle(_ title: String) -> String { + let lowered = title.lowercased() + let cleaned = lowered.map { $0.isLetter || $0.isNumber || $0.isWhitespace ? $0 : " " } + return String(cleaned).split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + // MARK: - Prompt + transcript + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + private static func platformLabel(_ platform: String) -> String { + switch platform { + case "telegram": return "Telegram" + case "whatsapp": return "WhatsApp" + default: return "iMessage" + } + } + + private static func formatTranscript( + _ messages: some Sequence, contact: ImportedContact + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + let themLabel = contact.displayName + return messages.map { message -> String in + let stamp = formatter.string(from: message.date) + let speaker = message.isFromMe ? "Me" : themLabel + let body = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + return "[\(stamp)] \(speaker): \(body)" + }.joined(separator: "\n") + } + + private static func buildPrompt(transcript: String, contact: ImportedContact) -> String { + let todayStr = dayFormatter.string(from: Date()) + return """ + Below is a chronological transcript of real text messages between the user (labeled "Me") \ + and "\(contact.displayName)". Today is \(todayStr). + + TRANSCRIPT: + \(transcript) + + Find every OPEN OBLIGATION the user ("Me") owes \(contact.displayName) — a specific thing \ + the user needs to do, send, or deliver that does NOT appear done anywhere later in the \ + visible conversation. There are TWO kinds, and you must catch BOTH: + + A) A promise the USER made: + - "I'll send you the deck tomorrow" + - "let me get back to you on the dates" + - "I'll call the landlord this week" + - "yeah let's do Thursday" when the user is the one agreeing to / proposing the plan + - "I'll look into it and let you know" + + B) The OTHER person asking for, chasing, or reminding about something the user owes them — \ + even if the user's original promise isn't visible in this window. This is the MOST common \ + real case, do not skip it: + - "can you get me the notes you promised" → the user owes them notes + - "did you send it?" / "still waiting on the deck" → the user owes a deliverable + - "can you send me the order script" / "can you get me X" → a direct request the user \ + hasn't fulfilled + - "you said you'd send the pics" → the user owes the pics + In case B the obligation is the USER's action to deliver the thing. Only extract it if the \ + request names a CONCRETE deliverable/action and the transcript does not show the user \ + already delivering it. + + What does NOT count (never extract these): + - Vague conversational filler ("we should hang out sometime", "lol yeah", "maybe") + - Something the OTHER person promised to do FOR the user (their obligation, not the user's) + - An obligation clearly resolved later in the thread (the user sent it, it happened, it was \ + cancelled, or the user says it's done) + - Reactions, opinions, or plans with no concrete deliverable the user owes + + Be conservative on vagueness, but a clear request/reminder for a named deliverable IS an \ + obligation — do not drop it just because the user never typed an explicit promise. + + For each real, still-open obligation, output: + - commitment_text: a short verb-first task the user should do, naming \ + "\(contact.displayName)" (e.g. "Send \(contact.displayName) the notes you promised"). 5–14 words. + - said_on: the date the obligation is anchored to, as yyyy-MM-dd (the user's promise date, \ + or the date of the other person's request/reminder). Empty string if you cannot tell. + - context: the ACTUAL message text that evidences the obligation, copied verbatim from the \ + transcript — the user's promise line, OR the other person's request/reminder line. Never \ + paraphrased or invented. + - confidence: 0.0–1.0 that this is a genuine, still-open obligation the user owes. + + Respond ONLY with valid JSON (no markdown, no code fences): + {"commitments": [{"commitment_text": "...", "said_on": "2026-06-14", "context": "exact line from transcript", "confidence": 0.9}]} + + If there are no clear open obligations, respond with {"commitments": []}. + """ + } + + /// Parse the model's JSON. Tolerant of code fences / trailing prose. Drops any entry whose + /// commitment text is empty. `saidOn` parses yyyy-MM-dd; unparseable/empty → nil. + static func parseCommitments(from text: String, contact: ImportedContact) + -> [ExtractedCommitment] + { + let jsonText = extractJSONObject(from: text) + guard + let data = jsonText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rows = parsed["commitments"] as? [[String: Any]] + else { return [] } + + return rows.compactMap { row -> ExtractedCommitment? in + let commitmentText = + (row["commitment_text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !commitmentText.isEmpty else { return nil } + + let context = + (row["context"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + let saidOnRaw = + (row["said_on"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let saidOn = saidOnRaw.isEmpty ? nil : dayFormatter.date(from: saidOnRaw) + + let confidence: Double + if let c = row["confidence"] as? Double { + confidence = c + } else if let c = row["confidence"] as? Int { + confidence = Double(c) + } else { + confidence = 0.5 + } + + return ExtractedCommitment( + commitmentText: commitmentText, saidOn: saidOn, context: context, + confidence: max(0.0, min(1.0, confidence))) + } + } + + private static func extractJSONObject(from text: String) -> String { + var responseText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if responseText.hasPrefix("```") { + if let firstNewline = responseText.firstIndex(of: "\n") { + responseText = String(responseText[responseText.index(after: firstNewline)...]) + } + if responseText.hasSuffix("```") { + responseText = String(responseText.dropLast(3)) + } + } + if let braceIndex = responseText.firstIndex(of: "{") { + responseText = String(responseText[braceIndex...]) + } + if let lastBrace = responseText.lastIndex(of: "}") { + responseText = String(responseText[...lastBrace]) + } + return responseText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - LLM plumbing (shared bridge, retry — mirrors AIClonePersonaService) + + private var sharedBridge: AgentBridge? + private var llmBusy = false + + private func acquireBridge() async throws -> AgentBridge { + if let bridge = sharedBridge, await bridge.isAlive { return bridge } + let bridge = AgentBridge(harnessMode: "piMono") + try await bridge.start() + sharedBridge = bridge + return bridge + } + + private func discardBridge() { + if let bridge = sharedBridge { + Task { await bridge.stop() } + } + sharedBridge = nil + } + + private static func isQuotaError(_ error: Error) -> Bool { + if case BridgeError.quotaExceeded = error { return true } + return false + } + + private func runLLM(prompt: String, system: String, label: String) async throws -> String { + while llmBusy { try? await Task.sleep(nanoseconds: 50_000_000) } + llmBusy = true + defer { llmBusy = false } + + let maxAttempts = 2 + var lastError: Error? + for attempt in 1...maxAttempts { + do { + let bridge = try await acquireBridge() + let result = try await bridge.query( + prompt: prompt, + systemPrompt: system, + model: ModelQoS.Claude.cloneVoice, + onTextDelta: { @Sendable _ in }, + onToolCall: { @Sendable _, _, _ in "" }, + onToolActivity: { @Sendable _, _, _, _ in } + ) + return result.text + } catch { + lastError = error + discardBridge() + if attempt < maxAttempts { + log("CommitmentExtractionService: \(label) attempt \(attempt) failed, retrying: \(error)") + if !Self.isQuotaError(error) { + try? await Task.sleep(nanoseconds: 800_000_000) + } + continue + } + log("CommitmentExtractionService: \(label) failed after \(attempt) attempts: \(error)") + } + } + throw CommitmentExtractionError.llmFailed(lastError?.localizedDescription ?? "") + } +} diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift index c8ecca0fff4..46a92d381f2 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift @@ -14,6 +14,7 @@ class TaskAssistantSettings { private let notificationsEnabledKey = "taskNotificationsEnabled" private let allowedAppsKey = "taskAllowedApps" private let browserKeywordsKey = "taskBrowserKeywords" + private let blockedContactIdsKey = "taskBlockedContactIds" // MARK: - Default Allowed Apps (Whitelist) @@ -360,6 +361,29 @@ class TaskAssistantSettings { } } + /// AI-Clone contact ids whose incoming messages must NOT be turned into tasks. This is a + /// per-person block for message-based task capture (dad's "get groceries" → task); it does + /// not affect screen-based extraction. Empty by default (everyone's messages count). + var blockedContactIds: Set { + get { Set((UserDefaults.standard.array(forKey: blockedContactIdsKey) as? [String]) ?? []) } + set { + UserDefaults.standard.set(Array(newValue), forKey: blockedContactIdsKey) + NotificationCenter.default.post(name: .assistantSettingsDidChange, object: nil) + } + } + + /// Whether message-based task capture is blocked for this contact id. + func isContactBlocked(_ contactId: String) -> Bool { + blockedContactIds.contains(contactId) + } + + /// Block or unblock a contact from generating tasks from their messages. + func setContactBlocked(_ blocked: Bool, contactId: String) { + var set = blockedContactIds + if blocked { set.insert(contactId) } else { set.remove(contactId) } + blockedContactIds = set + } + /// The full editable set of allowed apps. Initialized from defaults if user hasn't customized. var allowedApps: Set { get { diff --git a/desktop/macos/Desktop/Sources/Providers/AgentInstallHelp.swift b/desktop/macos/Desktop/Sources/Providers/AgentInstallHelp.swift new file mode 100644 index 00000000000..8afc7f5258c --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/AgentInstallHelp.swift @@ -0,0 +1,294 @@ +import Foundation + +struct LocalAgentInstallPlan: Equatable { + enum Kind: Equatable { + /// Provider binary is missing — run the official installer. + case install + /// Provider is installed but signed out — run its sign-in flow. + case authenticate + } + + let provider: AgentPillsManager.DirectedProvider + let installCommand: String? + let documentationURL: URL + let postInstallInstruction: String + var kind: Kind = .install + + var commandDisplay: String { + if kind == .authenticate { + return "Opens the sign-in page in your browser" + } + return installCommand ?? "Open setup documentation" + } + + var docsActionTitle: String { + "Open docs" + } +} + +struct AgentInstallRetryContext: Equatable { + let originalRequest: String + let rewrittenQuery: String + let title: String + let ack: String + let fromVoice: Bool +} + +enum AgentInstallPromptAction { + case beginConnection + case runSetup + case openDocs + /// Open Terminal with the manual model-setup command pre-typed (OpenClaw + /// without Claude Code). + case openTerminalSetup +} + +enum AgentInstallStatus: Equatable { + case ready + case confirming + case installing + case waitingForApproval(userCode: String?) + /// The provider needs the user to connect a model themselves in Terminal + /// (e.g. OpenClaw with no Claude Code credential to reuse). `command` is + /// shown in the prompt and pre-typed into Terminal. + case needsManualModelSetup(command: String) + case cancelled + case docsOpened + case connected + case commandFailed(exitCode: Int32, output: String) + case authFailed(message: String) + case finishedButMissing(output: String) + + var isBusy: Bool { + switch self { + case .installing, .waitingForApproval: return true + default: return false + } + } + + var automationValue: String { + switch self { + case .ready: return "ready" + case .confirming: return "confirming" + case .installing: return "installing" + case .waitingForApproval: return "waitingForApproval" + case .needsManualModelSetup: return "needsManualModelSetup" + case .cancelled: return "cancelled" + case .docsOpened: return "docsOpened" + case .connected: return "connected" + case .commandFailed: return "commandFailed" + case .authFailed: return "authFailed" + case .finishedButMissing: return "finishedButMissing" + } + } +} + +struct AgentInstallPromptState: Equatable { + static let setupConfirmationDelay: TimeInterval = 2.5 + + let plan: LocalAgentInstallPlan + let retryContext: AgentInstallRetryContext? + var status: AgentInstallStatus = .ready + var confirmingSince: Date? + + init( + plan: LocalAgentInstallPlan, + retryContext: AgentInstallRetryContext? = nil, + status: AgentInstallStatus = .ready, + confirmingSince: Date? = nil + ) { + self.plan = plan + self.retryContext = retryContext + self.status = status + self.confirmingSince = confirmingSince + } + + var detailText: String { + switch status { + case .ready: + if plan.kind == .authenticate { + // OpenClaw's connect is a non-interactive setup run, not a + // browser sign-in — describe it accurately. + if plan.provider == .openclaw { + return "Omi will set up OpenClaw automatically (Gateway + your Claude sign-in), then check the connection." + } + return "Omi will open the sign-in page in your browser, then wait for your approval." + } + if plan.installCommand != nil { + return "Omi will run the official setup command, then check the connection." + } + return plan.postInstallInstruction + case .confirming: + return "Ready to run the setup command shown below." + case .installing: + return "Connecting \(plan.provider.displayName)…" + case .waitingForApproval(let userCode): + if let userCode, !userCode.isEmpty { + return "Waiting for approval in your browser… If the page asks for a code, enter the one below." + } + return "Waiting for approval in your browser…" + case .needsManualModelSetup: + return "Claude Code isn't set up on this Mac, so OpenClaw needs its own model key. " + + "Get an openrouter.ai API key, run the command below with it, and " + + "Omi will connect automatically." + case .cancelled: + return "Connection cancelled." + case .docsOpened: + return "Opened setup docs. \(plan.postInstallInstruction)" + case .connected: + if retryContext != nil { + return "\(plan.provider.displayName) is connected. Retrying your request now." + } + return "\(plan.provider.displayName) is connected. Try your request again." + case .commandFailed(let exitCode, let output): + let suffix = output.isEmpty ? "" : " \(output)" + return "Setup failed with exit code \(exitCode).\(suffix)" + case .authFailed(let message): + let suffix = message.isEmpty ? "" : " \(message)" + return "Sign-in didn't finish.\(suffix) You can retry below." + case .finishedButMissing(let output): + let suffix = output.isEmpty ? "" : " \(output)" + return "Setup finished, but Omi still can't find \(plan.provider.displayName). \(plan.postInstallInstruction)\(suffix)" + } + } + + var primaryActionTitle: String { + switch status { + case .confirming: + return "Run setup" + case .authFailed: + return "Retry sign-in" + case .needsManualModelSetup: + return "Open Terminal" + default: + return "Connect \(plan.provider.displayName)" + } + } + + var primaryAction: AgentInstallPromptAction { + switch status { + case .confirming: + return .runSetup + case .needsManualModelSetup: + return .openTerminalSetup + default: + return .beginConnection + } + } + + var primaryActionEnabled: Bool { + switch status { + case .installing, .connected: + return false + case .confirming: + return confirmingSince == nil + default: + return true + } + } +} + +struct ShellInstallCommandResult: Equatable { + let exitCode: Int32 + let output: String +} + +enum AgentInstallCommandRunner { + static func run(_ command: String) async -> ShellInstallCommandResult { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let outputPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + process.standardOutput = outputPipe + process.standardError = outputPipe + + do { + try process.run() + process.waitUntilExit() + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + continuation.resume( + returning: ShellInstallCommandResult( + exitCode: process.terminationStatus, + output: trimmedInstallerOutput(output))) + } catch { + continuation.resume( + returning: ShellInstallCommandResult( + exitCode: -1, + output: error.localizedDescription)) + } + } + } + } + + private static func trimmedInstallerOutput(_ output: String) -> String { + let collapsed = output + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .suffix(6) + .joined(separator: " ") + guard collapsed.count > 700 else { return collapsed } + return String(collapsed.suffix(700)) + } +} + +extension AgentPillsManager.DirectedProvider { + /// Sign-in plan for a provider that is installed but has no usable + /// credentials. Only Hermes has an app-driven flow today (Nous Portal + /// device-code OAuth); other providers fall back to their install plan. + var authenticationPlan: LocalAgentInstallPlan { + switch self { + case .hermes: + return LocalAgentInstallPlan( + provider: self, + installCommand: nil, + documentationURL: URL(string: "https://hermes-agent.nousresearch.com/docs/integrations/nous-portal")!, + postInstallInstruction: "Approve the sign-in in your browser, then try your request again.", + kind: .authenticate) + case .openclaw: + // OpenClaw's "sign-in" is a non-interactive `openclaw onboard` run + // (Gateway daemon + model auth via the local Claude credential), so + // no browser step — the connect action does it all. + return LocalAgentInstallPlan( + provider: self, + installCommand: nil, + documentationURL: URL(string: "https://docs.openclaw.ai/cli/onboard")!, + postInstallInstruction: "Omi will set up OpenClaw's Gateway and connect it to your Claude sign-in, then try your request again.", + kind: .authenticate) + default: + return installPlan + } + } + + var installPlan: LocalAgentInstallPlan { + switch self { + case .hermes: + return LocalAgentInstallPlan( + provider: self, + installCommand: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", + documentationURL: URL(string: "https://hermes-agent.nousresearch.com/docs/getting-started/installation")!, + postInstallInstruction: "Reload your shell or restart Omi, then run hermes once if setup asks for it.") + case .openclaw: + return LocalAgentInstallPlan( + provider: self, + installCommand: "curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard", + documentationURL: URL(string: "https://docs.openclaw.ai/install")!, + postInstallInstruction: "Run openclaw onboard if account setup is still needed, then retry.") + case .codex: + return LocalAgentInstallPlan( + provider: self, + installCommand: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", + documentationURL: URL(string: "https://developers.openai.com/codex/cli")!, + postInstallInstruction: "Run codex once and sign in, then retry.") + case .claudeCode: + return LocalAgentInstallPlan( + provider: self, + installCommand: nil, + documentationURL: URL(string: "https://claude.ai/code")!, + postInstallInstruction: "Install Claude Code and sign in, then try again.") + } + } +} diff --git a/desktop/macos/Desktop/Sources/Providers/AgentRuntimeRouting.swift b/desktop/macos/Desktop/Sources/Providers/AgentRuntimeRouting.swift index b58b3fb9ed2..bbfaf58ff73 100644 --- a/desktop/macos/Desktop/Sources/Providers/AgentRuntimeRouting.swift +++ b/desktop/macos/Desktop/Sources/Providers/AgentRuntimeRouting.swift @@ -5,6 +5,7 @@ enum AgentHarnessMode: String { case acp = "acp" case hermes = "hermes" case openclaw = "openclaw" + case codex = "codex" } extension Optional where Wrapped == AgentHarnessMode { @@ -19,6 +20,7 @@ enum AgentAdapterId: String { case acp = "acp" case hermes = "hermes" case openclaw = "openclaw" + case codex = "codex" } enum AgentRuntimeRouting { @@ -45,6 +47,8 @@ enum AgentRuntimeRouting { return .hermes case AgentHarnessMode.openclaw.rawValue, "openClaw": return .openclaw + case AgentHarnessMode.codex.rawValue: + return .codex default: return nil } @@ -60,6 +64,8 @@ enum AgentRuntimeRouting { return .hermes case .openclaw: return .openclaw + case .codex: + return .codex } } } @@ -68,6 +74,10 @@ struct LocalAgentProviderAvailability: Equatable { enum Status: Equatable { case available(command: String) case missing + /// Installed on disk, but with no usable inference credential — the + /// runtime would fail with an opaque provider error, so route the + /// user to sign-in instead of the installer. + case needsAuthentication(command: String) } let provider: AgentPillsManager.DirectedProvider @@ -78,12 +88,27 @@ struct LocalAgentProviderAvailability: Equatable { return false } + var needsAuthentication: Bool { + if case .needsAuthentication = status { return true } + return false + } + var setupPrompt: String { + if needsAuthentication, provider == .hermes { + return "Hermes is installed but isn't signed in yet. I can open the Nous sign-in page in your browser to connect it." + } + if needsAuthentication, provider == .openclaw { + return "OpenClaw is installed but not set up yet. I can run its setup (Gateway + your Claude sign-in) automatically to connect it." + } switch provider { case .hermes: - return "I don't see Hermes installed. Make sure Hermes is installed first, then try again." + return "I don't see Hermes connected. I can run the official Hermes installer or open setup docs." case .openclaw: - return "I don't see OpenClaw installed. Make sure OpenClaw is installed first, then try again." + return "I don't see OpenClaw connected. I can run the official OpenClaw installer or open setup docs." + case .codex: + return "I don't see Codex connected. I can run the official Codex CLI installer or open setup docs." + case .claudeCode: + return "I don't see Claude Code connected. I can open setup docs so you can connect it." } } @@ -99,21 +124,66 @@ enum LocalAgentProviderDetector { fileManager: FileManager = .default, homeDirectory: String = NSHomeDirectory() ) -> LocalAgentProviderAvailability { + if provider == .claudeCode { + return claudeCodeAvailability( + provider: provider, + environment: environment, + fileManager: fileManager, + homeDirectory: homeDirectory + ) + } if let command = configuredCommand(for: provider, environment: environment) { return LocalAgentProviderAvailability(provider: provider, status: .available(command: command)) } if let path = firstExecutable( named: provider.executableName, + environment: environment, fileManager: fileManager, homeDirectory: homeDirectory ) { + if provider == .hermes, + !HermesAuthProbe.hasAnyInferenceCredential( + environment: environment, + fileManager: fileManager, + homeDirectory: homeDirectory + ) { + return LocalAgentProviderAvailability(provider: provider, status: .needsAuthentication(command: path)) + } + // The OpenClaw binary installs with `--no-onboard`, so a present + // binary is not yet a working agent: the Gateway daemon and model + // auth only exist after `openclaw onboard`. Surface that as a + // connect step instead of a silently broken "available". + if provider == .openclaw, + !OpenClawOnboardProbe.isOnboarded( + environment: environment, + fileManager: fileManager, + homeDirectory: homeDirectory + ) { + return LocalAgentProviderAvailability(provider: provider, status: .needsAuthentication(command: path)) + } return LocalAgentProviderAvailability(provider: provider, status: .available(command: path)) } return LocalAgentProviderAvailability(provider: provider, status: .missing) } + /// Resolved executable path for a provider's CLI, ignoring any + /// `OMI_*_ADAPTER_COMMAND` override (those may embed adapter arguments). + static func executablePath( + for provider: AgentPillsManager.DirectedProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) -> String? { + firstExecutable( + named: provider.executableName, + environment: environment, + fileManager: fileManager, + homeDirectory: homeDirectory + ) + } + static func isAvailable( _ provider: AgentPillsManager.DirectedProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -128,16 +198,65 @@ enum LocalAgentProviderDetector { environment: [String: String] ) -> String? { let key = provider.commandEnvironmentName + guard !key.isEmpty else { return nil } let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return value.isEmpty ? nil : value } + private static func claudeCodeAvailability( + provider: AgentPillsManager.DirectedProvider, + environment: [String: String], + fileManager: FileManager, + homeDirectory: String + ) -> LocalAgentProviderAvailability { + let configPath = (homeDirectory as NSString) + .appendingPathComponent("Library/Application Support/Claude/config.json") + if fileManager.fileExists(atPath: configPath), + let data = fileManager.contents(atPath: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tokenCache = json["oauth:tokenCache"] as? String, + !tokenCache.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return LocalAgentProviderAvailability(provider: provider, status: .available(command: "Claude Code OAuth token")) + } + + if keychainHasClaudeCodeCredentials() { + return LocalAgentProviderAvailability(provider: provider, status: .available(command: "Claude Code keychain token")) + } + + if let path = firstExecutable( + named: provider.executableName, + environment: environment, + fileManager: fileManager, + homeDirectory: homeDirectory + ) { + return LocalAgentProviderAvailability(provider: provider, status: .available(command: path)) + } + + return LocalAgentProviderAvailability(provider: provider, status: .missing) + } + + private static func keychainHasClaudeCodeCredentials() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + process.arguments = ["find-generic-password", "-s", "Claude Code-credentials"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + private static func firstExecutable( named name: String, + environment: [String: String], fileManager: FileManager, homeDirectory: String ) -> String? { - for dir in adapterActivationSearchDirectories(homeDirectory: homeDirectory) { + for dir in adapterActivationSearchDirectories(homeDirectory: homeDirectory, environment: environment) { let path = (dir as NSString).appendingPathComponent(name) if fileManager.isExecutableFile(atPath: path) { return path @@ -146,14 +265,30 @@ enum LocalAgentProviderDetector { return nil } - private static func adapterActivationSearchDirectories(homeDirectory: String) -> [String] { - [ + private static func adapterActivationSearchDirectories( + homeDirectory: String, + environment: [String: String] + ) -> [String] { + uniqueDirectories( + (environment["PATH"] ?? "").split(separator: ":").map(String.init) + [ "\(homeDirectory)/.hermes/hermes-agent/venv/bin", "\(homeDirectory)/.hermes/node/bin", "\(homeDirectory)/.hermes/hermes-agent", + "\(homeDirectory)/.openclaw/bin", + "\(homeDirectory)/.openclaw/node/bin", "\(homeDirectory)/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", - ] + ]) + } + + private static func uniqueDirectories(_ directories: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for directory in directories where !directory.isEmpty && !seen.contains(directory) { + seen.insert(directory) + result.append(directory) + } + return result } } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index c4378e8bffa..91a73943ac4 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1101,10 +1101,10 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio cachedMainSystemPrompt = mainSystemPrompt cachedFloatingSystemPrompt = floatingSystemPrompt cachedFloatingPillSystemPrompt = floatingPillSystemPrompt - // Hermes and OpenClaw ignore Omi's Claude model aliases, so leave + // Hermes, OpenClaw, and Codex ignore Omi's Claude model aliases, so leave // the model hint nil to avoid recording a model ID in binding metadata // that could trigger spurious context-changed sessions later. - let usesNativeModelChoice = activeBridgeHarness == "hermes" || activeBridgeHarness == "openclaw" + let usesNativeModelChoice = activeBridgeHarness == "hermes" || activeBridgeHarness == "openclaw" || activeBridgeHarness == "codex" let mainWarmupModel = usesNativeModelChoice ? nil : ModelQoS.Claude.chat let floatingWarmupModel = usesNativeModelChoice ? nil : floatingModel await agentBridge.warmupSession(cwd: workingDirectory, sessions: [ @@ -2888,7 +2888,8 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio surfaceRef: AgentSurfaceReference? = nil, legacyClientScope: String? = nil, resume: String? = nil, - imageData: Data? = nil + imageData: Data? = nil, + allowAdapterAutoSelection: Bool = false ) async -> String? { let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedText.isEmpty else { return nil } @@ -3160,10 +3161,12 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio } } - // Query the active bridge with streaming. Hermes and OpenClaw do not + // Query the active bridge with streaming. Hermes, OpenClaw, and Codex do not // accept Omi's Claude model aliases, so leave model choice to the - // harness default when either native adapter is active. - let usesNativeModelChoice = activeBridgeHarness == "hermes" || activeBridgeHarness == "openclaw" + // harness default when a native adapter is active. + let allowsRuntimeAdapterSelection = allowAdapterAutoSelection && bridgeHarnessOverride == nil + let usesNativeModelChoice = + allowsRuntimeAdapterSelection || activeBridgeHarness == "hermes" || activeBridgeHarness == "openclaw" || activeBridgeHarness == "codex" let effectiveRequestModel = usesNativeModelChoice ? nil : (model ?? modelOverride) // Callbacks for agent bridge @@ -3366,6 +3369,7 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio model: effectiveRequestModel, resume: resume, imageData: effectiveImageData, + allowAdapterAutoSelection: allowsRuntimeAdapterSelection, onTextDelta: textDeltaHandler, onToolCall: toolCallHandler, onToolActivity: toolActivityHandler, @@ -3385,6 +3389,9 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio } } ) + if let adapterId = queryResult.adapterId { + log("ChatProvider: agent query used adapter \(adapterId)") + } // Flush any remaining buffered streaming text before finalizing streamingFlushWorkItem?.cancel() @@ -3543,9 +3550,11 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio // providers, so skip the POST here too. Use the actual harness, not // @AppStorage bridgeMode, because directed Hermes/OpenClaw pills can // override the harness without changing the user's global preference. - let effectiveHarness = activeBridgeHarness - let isPiMonoHarness = effectiveHarness == Self.harnessMode(for: .piMono) - let isUserClaudeHarness = effectiveHarness == Self.harnessMode(for: .userClaude) + let effectiveAdapterId = + queryResult.adapterId + ?? AgentRuntimeProcess.adapterId(forHarnessMode: activeBridgeHarness) + let isPiMonoHarness = effectiveAdapterId == AgentAdapterId.piMono.rawValue + let isUserClaudeHarness = effectiveAdapterId == AgentAdapterId.acp.rawValue if isUserClaudeHarness { let r = queryResult Task.detached(priority: .background) { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift index 753bec1e75c..f8f0cd41299 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift @@ -473,9 +473,11 @@ class ChatToolExecutor { switch providerName { case "openclaw": directedProvider = .openclaw case "hermes": directedProvider = .hermes + case "codex": directedProvider = .codex + case "claudecode": directedProvider = .claudeCode case "": directedProvider = nil default: - return "Error: Unsupported provider '\(providerName)'. Supported providers: openclaw, hermes." + return "Error: Unsupported provider '\(providerName)'. Supported providers: openclaw, hermes, codex, claudeCode." } if let directedProvider { let availability = LocalAgentProviderDetector.availability(for: directedProvider) diff --git a/desktop/macos/Desktop/Sources/Providers/HermesConnectService.swift b/desktop/macos/Desktop/Sources/Providers/HermesConnectService.swift new file mode 100644 index 00000000000..0a390af8f40 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/HermesConnectService.swift @@ -0,0 +1,355 @@ +import AppKit +import Foundation + +/// Fast, file-based detection of whether Hermes has any usable inference +/// credential. Hermes stores OAuth state and its credential pool in +/// `/auth.json` and provider API keys in `/.env`. +/// Erring toward "has credentials" is deliberate: a false positive just +/// preserves today's behavior (the runtime tries and reports its own error), +/// while a false negative would hijack a working setup into the connect flow. +enum HermesAuthProbe { + static func hermesHome( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> String { + let override = environment["HERMES_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !override.isEmpty { return override } + return (homeDirectory as NSString).appendingPathComponent(".hermes") + } + + /// True when Hermes has at least one credential signal: a Nous OAuth + /// refresh token, any provider state or pool entry in auth.json, an API + /// key in `/.env`, or a known provider key in the process + /// environment. + static func hasAnyInferenceCredential( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) -> Bool { + let home = hermesHome(environment: environment, homeDirectory: homeDirectory) + if authStoreHasCredentials(atPath: (home as NSString).appendingPathComponent("auth.json"), fileManager: fileManager) { + return true + } + if envFileHasAPIKey(atPath: (home as NSString).appendingPathComponent(".env"), fileManager: fileManager) { + return true + } + return environmentHasKnownProviderKey(environment) + } + + /// True when the Nous provider specifically is signed in (refresh token + /// present in auth.json). Used to confirm success after the connect flow. + static func isNousAuthenticated( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) -> Bool { + let home = hermesHome(environment: environment, homeDirectory: homeDirectory) + let path = (home as NSString).appendingPathComponent("auth.json") + guard let store = readJSONObject(atPath: path, fileManager: fileManager), + let providers = store["providers"] as? [String: Any], + let nous = providers["nous"] as? [String: Any] + else { return false } + return hasNonEmptyString(nous, key: "refresh_token") + } + + private static func authStoreHasCredentials(atPath path: String, fileManager: FileManager) -> Bool { + guard let store = readJSONObject(atPath: path, fileManager: fileManager) else { return false } + + if let providers = store["providers"] as? [String: Any] { + for value in providers.values { + guard let state = value as? [String: Any] else { continue } + if hasNonEmptyString(state, key: "refresh_token") || hasNonEmptyString(state, key: "access_token") { + return true + } + } + } + + if let pool = store["credential_pool"] as? [String: Any] { + for value in pool.values { + if let entries = value as? [Any], !entries.isEmpty { + return true + } + } + } + + return false + } + + private static func envFileHasAPIKey(atPath path: String, fileManager: FileManager) -> Bool { + guard fileManager.fileExists(atPath: path), + let data = fileManager.contents(atPath: path), + let content = String(data: data, encoding: .utf8) + else { return false } + + for line in content.split(whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.hasPrefix("#"), let equals = trimmed.firstIndex(of: "=") else { continue } + let name = String(trimmed[.. Bool { + let knownKeys = [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "DEEPSEEK_API_KEY", "XAI_API_KEY", + "NOUS_API_KEY", "NVIDIA_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", + "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", "HF_TOKEN", + ] + return knownKeys.contains { !(environment[$0] ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private static func readJSONObject(atPath path: String, fileManager: FileManager) -> [String: Any]? { + guard fileManager.fileExists(atPath: path), + let data = fileManager.contents(atPath: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return json + } + + private static func hasNonEmptyString(_ dict: [String: Any], key: String) -> Bool { + guard let value = dict[key] as? String else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +/// Parses the stdout of `hermes auth add nous --type oauth --no-browser`, +/// which prints (RFC 8628 device-code flow): +/// +/// To continue: +/// 1. Open: https://portal.nousresearch.com/activate?user_code=XXXX +/// 2. If prompted, enter code: XXXX +/// Waiting for approval (polling every 5s)... +/// +/// Kept as a standalone type so it is unit-testable without a process. +struct HermesDeviceCodeOutputParser { + private(set) var verificationURL: URL? + private(set) var userCode: String? + + mutating func consume(line rawLine: String) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if verificationURL == nil, let range = line.range(of: "Open: ") { + let candidate = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + if let url = URL(string: candidate), let scheme = url.scheme, scheme.hasPrefix("http") { + verificationURL = url + } + } + if userCode == nil, let range = line.range(of: "enter code: ") { + let code = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + if !code.isEmpty { + userCode = code + } + } + } +} + +/// Runs the Hermes → Nous Portal sign-in from inside the app. +/// +/// Hermes has no non-interactive API-key path for Nous (OAuth device-code +/// only), so this wraps the CLI's own flow: spawn +/// `hermes auth add nous --type oauth --no-browser`, parse the verification +/// URL and user code from stdout, open the URL in the default browser, and +/// wait — the CLI polls the token endpoint itself and exits 0 once the user +/// approves in the browser (writing tokens to `~/.hermes/auth.json`). +@MainActor +final class HermesConnectService: ObservableObject { + enum Phase: Equatable { + case idle + case starting + case waitingForApproval(verificationURL: URL, userCode: String?) + case connected + case failed(message: String) + + var isBusy: Bool { + switch self { + case .starting, .waitingForApproval: return true + case .idle, .connected, .failed: return false + } + } + + var automationValue: String { + switch self { + case .idle: return "idle" + case .starting: return "starting" + case .waitingForApproval: return "waitingForApproval" + case .connected: return "connected" + case .failed: return "failed" + } + } + } + + static let shared = HermesConnectService() + + @Published private(set) var phase: Phase = .idle + + /// Device codes expire server-side (typically 15 minutes); this is a + /// safety net so an orphaned CLI can't wait forever. + private static let approvalTimeout: TimeInterval = 20 * 60 + + private var process: Process? + private var timeoutTask: Task? + private let openURL: (URL) -> Void + + init(openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) }) { + self.openURL = openURL + } + + var isNousAuthenticated: Bool { + HermesAuthProbe.isNousAuthenticated() + } + + func connect() { + guard !phase.isBusy else { return } + + // .needsAuthentication is the expected state here; only a genuinely + // absent executable should stop the flow. + guard let executable = hermesExecutablePath() else { + phase = .failed(message: "Hermes is not installed. Install it first, then retry.") + return + } + + phase = .starting + log("HermesConnect: starting device-code flow via \(executable)") + + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["auth", "add", "nous", "--type", "oauth", "--no-browser"] + // Null stdin: if a shared credential store exists the CLI asks + // "Import these credentials? [Y/n]" — EOF makes it default to yes. + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdout + process.standardError = stdout + // Hermes is a Python CLI; without a tty its prints are block-buffered + // and the verification URL would only arrive at process exit. + var environment = ProcessInfo.processInfo.environment + environment["PYTHONUNBUFFERED"] = "1" + process.environment = environment + + var lineBuffer = "" + var transcript = "" + var parser = HermesDeviceCodeOutputParser() + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + transcript += chunk + lineBuffer += chunk + while let newline = lineBuffer.firstIndex(of: "\n") { + let line = String(lineBuffer[.. String { + let lines = transcript + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("Waiting for approval") } + let tail = lines.suffix(3).joined(separator: " ") + guard tail.count > 300 else { return tail } + return String(tail.suffix(300)) + } + + private func hermesExecutablePath() -> String? { + LocalAgentProviderDetector.executablePath(for: .hermes) + } + + private func log(_ message: String) { + NSLog("%@", message) + } +} diff --git a/desktop/macos/Desktop/Sources/Providers/HermesModelProvisioner.swift b/desktop/macos/Desktop/Sources/Providers/HermesModelProvisioner.swift new file mode 100644 index 00000000000..91fd4837a12 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/HermesModelProvisioner.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Keeps Hermes pointed at a **free** Nous model so it works without paid +/// credits. Hermes' CLI writes `~/.hermes/config.yaml` with `model.default` +/// set to a paid model (e.g. `qwen/qwen3-235b-a22b-2507`), which 404s on a +/// zero-credit account ("requires available credits"). The app owns this +/// choice: whenever we confirm Hermes is connected (fresh device-code sign-in +/// or an existing authenticated install), we pin the default to a known free +/// model so a live run never hits the paid-credit wall. +/// +/// The rewrite is line-based (no YAML dependency), idempotent, and only touches +/// `model.default`; everything else in the file is preserved verbatim. +enum HermesModelProvisioner { + /// A Nous model priced at $0.00/1M — confirmed to answer on the free tier. + static let freeDefaultModel = "stepfun/step-3.7-flash:free" + + static func defaultConfigPath( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> String { + let home = HermesAuthProbe.hermesHome(environment: environment, homeDirectory: homeDirectory) + return (home as NSString).appendingPathComponent("config.yaml") + } + + /// Ensures `model.default` in the Hermes config is a free model. Returns + /// true when the file was created or modified. Safe to call repeatedly. + @discardableResult + static func ensureFreeDefaultModel( + configPath: String = defaultConfigPath(), + fileManager: FileManager = .default + ) -> Bool { + let existing: String + if fileManager.fileExists(atPath: configPath), + let data = fileManager.contents(atPath: configPath), + let text = String(data: data, encoding: .utf8) { + existing = text + } else { + existing = "" + } + + guard let updated = rewrite(existing) else { return false } + + do { + let dir = (configPath as NSString).deletingLastPathComponent + if !dir.isEmpty, !fileManager.fileExists(atPath: dir) { + try fileManager.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + try updated.write(toFile: configPath, atomically: true, encoding: .utf8) + NSLog("HermesModelProvisioner: pinned model.default to %@ in %@", freeDefaultModel, configPath) + return true + } catch { + NSLog("HermesModelProvisioner: failed to write %@: %@", configPath, error.localizedDescription) + return false + } + } + + /// True when a `model.default` value already names a free (`:free`) model. + static func isFreeModel(_ value: String) -> Bool { + value.trimmingCharacters(in: .whitespaces).hasSuffix(":free") + } + + /// Pure transform: returns the rewritten YAML, or nil when no change is + /// needed (the default is already a free model). + /// + /// Handles three shapes: + /// - a top-level `model:` block with a `default:` child (replace value), + /// - a `model:` block without a `default:` child (insert one), and + /// - no `model:` block at all / empty file (prepend a minimal block). + static func rewrite(_ contents: String) -> String? { + let desired = " default: \(freeDefaultModel)" + var lines = contents.isEmpty ? [] : contents.components(separatedBy: "\n") + + guard let modelIdx = topLevelKeyIndex(in: lines, key: "model") else { + // No model block — prepend one. Keep provider on nous for OAuth. + let block = ["model:", desired, " provider: nous"] + let body = lines.isEmpty ? [] : lines + return (block + body).joined(separator: "\n") + } + + // Scan the model block's children (more-indented lines) for `default:`. + var defaultIdx: Int? + var i = modelIdx + 1 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + let isIndented = line.first == " " || line.first == "\t" + if !trimmed.isEmpty && !isIndented { break } // next top-level key ends the block + if isIndented && trimmed.hasPrefix("default:") { + defaultIdx = i + break + } + i += 1 + } + + if let defaultIdx { + let currentValue = lines[defaultIdx] + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: "default:", with: "") + .trimmingCharacters(in: .whitespaces) + if isFreeModel(currentValue) { return nil } // already free — nothing to do + lines[defaultIdx] = desired + return lines.joined(separator: "\n") + } + + // model block exists but has no default — insert as its first child. + lines.insert(desired, at: modelIdx + 1) + return lines.joined(separator: "\n") + } + + /// Index of a top-level (unindented) `key:` line, if present. + private static func topLevelKeyIndex(in lines: [String], key: String) -> Int? { + for (idx, line) in lines.enumerated() { + guard line.first != " ", line.first != "\t" else { continue } + if line.trimmingCharacters(in: .whitespaces) == "\(key):" { + return idx + } + } + return nil + } +} diff --git a/desktop/macos/Desktop/Sources/Providers/OpenClawConnectService.swift b/desktop/macos/Desktop/Sources/Providers/OpenClawConnectService.swift new file mode 100644 index 00000000000..d0f2a1b0064 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/OpenClawConnectService.swift @@ -0,0 +1,250 @@ +import AppKit +import Foundation + +/// Runs OpenClaw's onboarding from inside the app so a fresh install becomes a +/// working agent without the user dropping to a terminal. +/// +/// Unlike Hermes (browser device-code OAuth), `openclaw onboard` runs fully +/// non-interactively: it installs/starts the Gateway daemon and wires the +/// default model to the user's local Claude sign-in (`--auth-choice +/// anthropic-cli`, reusing `~/.claude/.credentials.json` — the same credential +/// Claude Code, the app's native default agent, already uses). So this is a +/// one-shot command with no user interaction, mapped to a simple phase model. +@MainActor +final class OpenClawConnectService: ObservableObject { + enum Phase: Equatable { + case idle + case onboarding + /// Claude Code isn't available on this Mac, so `--auth-choice + /// anthropic-cli` has no credential to reuse. The user connects + /// OpenClaw to a model themselves (Terminal + their own API key) + /// while the app watches for the config to appear. + case needsManualModelSetup + case connected + case failed(message: String) + + var isBusy: Bool { self == .onboarding } + + var automationValue: String { + switch self { + case .idle: return "idle" + case .onboarding: return "onboarding" + case .needsManualModelSetup: return "needsManualModelSetup" + case .connected: return "connected" + case .failed: return "failed" + } + } + } + + static let shared = OpenClawConnectService() + + @Published private(set) var phase: Phase = .idle + + private var task: Task? + private var manualWatchTask: Task? + + /// Test-only: forces the Claude Code availability probe to a fixed value + /// so the no-Claude-Code fallback can be exercised without touching the + /// user's real credentials. Settable only through the local automation + /// bridge, which exists on non-prod bundles only. + var claudeCodeAvailabilityOverrideForTesting: Bool? + + private var isClaudeCodeAvailableForOnboard: Bool { + if let forced = claudeCodeAvailabilityOverrideForTesting { return forced } + return LocalAgentProviderDetector.isAvailable(.claudeCode) + } + + var isOnboarded: Bool { OpenClawOnboardProbe.isOnboarded() } + + /// Idempotent, non-interactive onboarding. Installs the Gateway daemon and + /// configures the default model via the local Claude credential. When no + /// Claude credential exists, falls back to a user-driven Terminal setup + /// with the user's own model API key (see `manualModelSetupCommand`). + func connect() { + guard !phase.isBusy else { return } + guard let executable = openClawExecutablePath() else { + phase = .failed(message: "OpenClaw is not installed. Install it first, then retry.") + return + } + if isOnboarded { + phase = .connected + return + } + guard isClaudeCodeAvailableForOnboard else { + // No keychain credential, config token, or claude binary — + // `anthropic-cli` auth would fail. Hand the model connection to + // the user and watch for the onboarded config to appear. + phase = .needsManualModelSetup + startManualOnboardWatch() + return + } + + phase = .onboarding + log("OpenClawConnect: running non-interactive onboarding via \(executable)") + + task = Task { [weak self] in + let result = await Self.runOnboard(executable: executable) + guard let self else { return } + // Trust the on-disk config over the exit code: a non-zero exit with + // a fully-written config (e.g. an optional post-step failing) still + // yields a working agent. + if OpenClawOnboardProbe.isOnboarded() { + self.log("OpenClawConnect: onboarded (exit \(result.exitCode))") + self.phase = .connected + } else { + let detail = result.output.isEmpty ? "" : " \(result.output)" + self.log("OpenClawConnect: onboarding failed (exit \(result.exitCode))\(detail)") + self.phase = .failed( + message: detail.isEmpty ? "OpenClaw setup failed (exit \(result.exitCode))." : detail) + } + } + } + + func cancel() { + manualWatchTask?.cancel() + manualWatchTask = nil + guard phase.isBusy || phase == .needsManualModelSetup else { return } + task?.cancel() + task = nil + phase = .idle + } + + /// Re-check onboarding on demand (e.g. when a settings card appears). + func refreshConnectionState() { + guard !phase.isBusy else { return } + if case .failed = phase { return } + if phase == .needsManualModelSetup, !isOnboarded { return } + phase = isOnboarded ? .connected : .idle + } + + /// Placeholder the user replaces with their own key in Terminal. The key + /// goes straight into OpenClaw's own credential store — the app never + /// sees or stores it. + static let manualSetupKeyPlaceholder = "YOUR_OPENROUTER_API_KEY" + + /// The Terminal command for connecting OpenClaw to a model without Claude + /// Code. `--auth-choice openrouter-api-key` is the broadest single-key + /// option `openclaw onboard` supports (one OpenRouter key covers many + /// models — the same bring-your-own-key shape as the Hermes fallback); + /// the remaining flags mirror `onboardArguments`. + static let manualModelSetupCommand: String = + "openclaw onboard --non-interactive --accept-risk " + + "--auth-choice openrouter-api-key --openrouter-api-key \(manualSetupKeyPlaceholder) " + + "--install-daemon --flow quickstart " + + "--skip-channels --skip-search --skip-skills --skip-hooks --skip-ui" + + /// Open a fresh Terminal window with the manual setup command pre-typed + /// but NOT executed: `print -z` pushes the text into zsh's next prompt + /// buffer, so the user swaps in their real API key and presses return + /// themselves. (zsh is the macOS default shell; on another shell the + /// command is still visible in the prompt UI to copy.) + func openTerminalPreloadedWithManualSetup() { + // `osascript` subprocess rather than NSAppleScript: NSAppleScript is + // main-thread-only and silently flaky off it. + let script = """ + tell application "Terminal" + activate + do script "print -z '\(Self.manualModelSetupCommand)'" + end tell + """ + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + process.standardOutput = FileHandle.nullDevice + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + NSLog("OpenClawConnect: failed to open Terminal: %@", + String(data: data, encoding: .utf8) ?? "unknown error") + } + } catch { + NSLog("OpenClawConnect: failed to open Terminal: %@", error.localizedDescription) + } + } + } + + /// Poll the on-disk config while the user completes the Terminal setup; + /// flips to `.connected` the moment onboarding lands. Bounded (~15 min) + /// so an abandoned prompt doesn't poll forever. + private func startManualOnboardWatch() { + manualWatchTask?.cancel() + manualWatchTask = Task { [weak self] in + for _ in 0..<450 { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if Task.isCancelled { return } + guard let self, self.phase == .needsManualModelSetup else { return } + if OpenClawOnboardProbe.isOnboarded() { + self.log("OpenClawConnect: manual model setup detected — connected") + self.phase = .connected + return + } + } + } + } + + /// The exact arguments used to onboard non-interactively. Kept here (not + /// inlined) so it is unit-testable and self-documenting. + static let onboardArguments: [String] = [ + "onboard", + "--non-interactive", + "--accept-risk", + "--auth-choice", "anthropic-cli", + "--install-daemon", + "--flow", "quickstart", + "--skip-channels", + "--skip-search", + "--skip-skills", + "--skip-hooks", + "--skip-ui", + ] + + private static func runOnboard(executable: String) async -> ShellInstallCommandResult { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = onboardArguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + continuation.resume(returning: ShellInstallCommandResult( + exitCode: process.terminationStatus, + output: Self.tail(output))) + } catch { + continuation.resume(returning: ShellInstallCommandResult( + exitCode: -1, output: error.localizedDescription)) + } + } + } + } + + private static func tail(_ output: String) -> String { + let collapsed = output + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .suffix(6) + .joined(separator: " ") + guard collapsed.count > 700 else { return collapsed } + return String(collapsed.suffix(700)) + } + + private func openClawExecutablePath() -> String? { + LocalAgentProviderDetector.executablePath(for: .openclaw) + } + + private func log(_ message: String) { + NSLog("%@", message) + } +} diff --git a/desktop/macos/Desktop/Sources/Providers/OpenClawOnboardProbe.swift b/desktop/macos/Desktop/Sources/Providers/OpenClawOnboardProbe.swift new file mode 100644 index 00000000000..2be4c95ce95 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/OpenClawOnboardProbe.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Detects whether OpenClaw has been **onboarded** — i.e. `openclaw onboard` +/// has generated a usable config (a Gateway + a default model runtime). +/// +/// This is distinct from "installed": the app's install command runs +/// `... install.sh | bash -s -- --no-onboard`, which drops the `openclaw` +/// binary but writes no `~/.openclaw/openclaw.json`. Without onboarding the +/// Gateway daemon and model auth are absent, so `openclaw acp` can't actually +/// run an agent. Treating that state as "available" is what left fresh users +/// with a silently non-working OpenClaw. +/// +/// File-based (no process spawn), mirroring `HermesAuthProbe`. +enum OpenClawOnboardProbe { + static func openClawHome( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> String { + let override = environment["OPENCLAW_STATE_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !override.isEmpty { return override } + return (homeDirectory as NSString).appendingPathComponent(".openclaw") + } + + static func configPath( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> String { + let override = environment["OPENCLAW_CONFIG_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !override.isEmpty { return override } + let home = openClawHome(environment: environment, homeDirectory: homeDirectory) + return (home as NSString).appendingPathComponent("openclaw.json") + } + + /// True when the config exists and describes a usable setup: a Gateway + /// (port) plus a configured default model. Erring toward "not onboarded" + /// is deliberate — a false negative just surfaces the connect prompt (which + /// re-runs a safe, idempotent onboard), while a false positive would let a + /// broken install look ready and fail opaquely at agent-run time. + static func isOnboarded( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) -> Bool { + let path = configPath(environment: environment, homeDirectory: homeDirectory) + guard fileManager.fileExists(atPath: path), + let data = fileManager.contents(atPath: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + return hasGateway(json) && hasDefaultModel(json) + } + + private static func hasGateway(_ json: [String: Any]) -> Bool { + guard let gateway = json["gateway"] as? [String: Any] else { return false } + // A port (Int or numeric String) marks a configured Gateway. + if let port = gateway["port"] as? Int, port > 0 { return true } + if let portString = gateway["port"] as? String, let port = Int(portString), port > 0 { return true } + return false + } + + private static func hasDefaultModel(_ json: [String: Any]) -> Bool { + guard let agents = json["agents"] as? [String: Any], + let defaults = agents["defaults"] as? [String: Any], + let model = defaults["model"] as? [String: Any], + let primary = model["primary"] as? String + else { return false } + return !primary.trimmingCharacters(in: .whitespaces).isEmpty + } +} diff --git a/desktop/macos/Desktop/Sources/TelegramImportService.swift b/desktop/macos/Desktop/Sources/TelegramImportService.swift new file mode 100644 index 00000000000..3eed5533458 --- /dev/null +++ b/desktop/macos/Desktop/Sources/TelegramImportService.swift @@ -0,0 +1,302 @@ +import Foundation + +// MARK: - Parsed models + +struct TelegramParsedMessage: Sendable { + let senderName: String? + let senderID: String? + let text: String + let date: Date +} + +struct TelegramSender: Sendable, Identifiable { + let senderID: String? + let name: String? + let messageCount: Int + var id: String { senderID ?? name ?? "unknown" } +} + +struct TelegramParsedChat: Sendable { + let id: Int64 + let name: String? + let type: String + var isPersonalChat: Bool { type == "personal_chat" } + let messages: [TelegramParsedMessage] + let senders: [TelegramSender] +} + +enum TelegramImportError: LocalizedError { + case unrecognizedFormat + + var errorDescription: String? { + switch self { + case .unrecognizedFormat: + return "This file doesn't look like a Telegram export. In Telegram Desktop use " + + "Settings → Advanced → Export Telegram Data with format set to JSON, then pick result.json." + } + } +} + +private enum TelegramRawTextPiece: Decodable { + case plain(String) + case entity(String) + + private enum CodingKeys: String, CodingKey { case text } + + init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), let s = try? single.decode(String.self) { + self = .plain(s) + return + } + let object = try decoder.container(keyedBy: CodingKeys.self) + self = .entity((try? object.decode(String.self, forKey: .text)) ?? "") + } + + var text: String { + switch self { + case .plain(let s), .entity(let s): return s + } + } +} + +private enum TelegramRawText: Decodable { + case plain(String) + case rich([TelegramRawTextPiece]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let s = try? container.decode(String.self) { + self = .plain(s) + } else if let pieces = try? container.decode([TelegramRawTextPiece].self) { + self = .rich(pieces) + } else { + self = .plain("") + } + } + + var flattened: String { + switch self { + case .plain(let s): return s + case .rich(let pieces): return pieces.map(\.text).joined() + } + } +} + +private struct TelegramRawMessage: Decodable { + let type: String? + let date: String? + let dateUnixtime: String? + let from: String? + let fromID: String? + let text: TelegramRawText? + + private enum CodingKeys: String, CodingKey { + case type, date, from, text + case dateUnixtime = "date_unixtime" + case fromID = "from_id" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = try? c.decode(String.self, forKey: .type) + date = try? c.decode(String.self, forKey: .date) + if let s = try? c.decode(String.self, forKey: .dateUnixtime) { + dateUnixtime = s + } else if let n = try? c.decode(Int64.self, forKey: .dateUnixtime) { + dateUnixtime = String(n) + } else { + dateUnixtime = nil + } + from = try? c.decode(String.self, forKey: .from) + if let s = try? c.decode(String.self, forKey: .fromID) { + fromID = s + } else if let n = try? c.decode(Int64.self, forKey: .fromID) { + fromID = String(n) + } else { + fromID = nil + } + text = try? c.decode(TelegramRawText.self, forKey: .text) + } +} + +private struct TelegramRawChat: Decodable { + let id: Int64? + let name: String? + let type: String? + let messages: [TelegramRawMessage]? +} + +private struct TelegramRawChatList: Decodable { + let list: [TelegramRawChat]? +} + +private struct TelegramRawExport: Decodable { + let chats: TelegramRawChatList? + let leftChats: TelegramRawChatList? + + private enum CodingKeys: String, CodingKey { + case chats + case leftChats = "left_chats" + } +} + +func parseTelegramExport(jsonData: Data) throws -> [TelegramParsedChat] { + let decoder = JSONDecoder() + + var rawChats: [TelegramRawChat] = [] + if let export = try? decoder.decode(TelegramRawExport.self, from: jsonData), + export.chats?.list != nil || export.leftChats?.list != nil + { + rawChats = (export.chats?.list ?? []) + (export.leftChats?.list ?? []) + } else if let single = try? decoder.decode(TelegramRawChat.self, from: jsonData), + single.messages != nil + { + rawChats = [single] + } else { + throw TelegramImportError.unrecognizedFormat + } + + let isoFormatter = DateFormatter() + isoFormatter.locale = Locale(identifier: "en_US_POSIX") + isoFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + isoFormatter.timeZone = .current + + func resolveDate(_ message: TelegramRawMessage) -> Date? { + if let unix = message.dateUnixtime, let seconds = TimeInterval(unix) { + return Date(timeIntervalSince1970: seconds) + } + if let iso = message.date { + return isoFormatter.date(from: iso) + } + return nil + } + + return rawChats.compactMap { raw -> TelegramParsedChat? in + guard let id = raw.id, let type = raw.type else { return nil } + + var messages: [TelegramParsedMessage] = [] + var senderOrder: [String] = [] + var senderInfo: [String: (id: String?, name: String?, count: Int)] = [:] + + for rawMessage in raw.messages ?? [] { + guard (rawMessage.type ?? "message") == "message" else { continue } + let text = rawMessage.text?.flattened + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { continue } + guard let date = resolveDate(rawMessage) else { continue } + + messages.append( + TelegramParsedMessage( + senderName: rawMessage.from, senderID: rawMessage.fromID, text: text, date: date)) + + let key = rawMessage.fromID ?? rawMessage.from ?? "unknown" + if var existing = senderInfo[key] { + existing.count += 1 + if existing.name == nil { existing.name = rawMessage.from } + senderInfo[key] = existing + } else { + senderOrder.append(key) + senderInfo[key] = (id: rawMessage.fromID, name: rawMessage.from, count: 1) + } + } + + guard !messages.isEmpty else { return nil } + + let senders = + senderOrder + .compactMap { key -> TelegramSender? in + guard let info = senderInfo[key] else { return nil } + return TelegramSender(senderID: info.id, name: info.name, messageCount: info.count) + } + .sorted { $0.messageCount > $1.messageCount } + + return TelegramParsedChat( + id: id, name: raw.name, type: type, messages: messages, senders: senders) + } +} + +func telegramSelfUserID(jsonData: Data) -> String? { + struct PersonalInfoEnvelope: Decodable { + struct PersonalInfo: Decodable { + let userID: Int64? + private enum CodingKeys: String, CodingKey { case userID = "user_id" } + } + let personalInformation: PersonalInfo? + private enum CodingKeys: String, CodingKey { + case personalInformation = "personal_information" + } + } + guard + let envelope = try? JSONDecoder().decode(PersonalInfoEnvelope.self, from: jsonData), + let userID = envelope.personalInformation?.userID + else { return nil } + return "user\(userID)" +} + +// MARK: - Reader + +/// Session-scoped reader over one imported Telegram Desktop JSON export. Mirrors +/// `IMessageReaderService`'s actor + `shared` + `ImportedContact`/`ImportedMessage` +/// conversion shape so `AIClonePage` can treat all platforms uniformly. +actor TelegramImportService { + static let shared = TelegramImportService() + + private var chats: [TelegramParsedChat] = [] + private var selfID: String? = + UserDefaults.standard.string(forKey: "aiCloneTelegramSelfID") + + /// Load a `result.json` (or a directory containing one). Returns senders needing + /// disambiguation, or `[]` if self was auto-detected from `personal_information`. + func importExport(at url: URL) throws -> [TelegramSender] { + let resolvedURL = url.hasDirectoryPath ? url.appendingPathComponent("result.json") : url + let data = try Data(contentsOf: resolvedURL) + chats = try parseTelegramExport(jsonData: data) + if let auto = telegramSelfUserID(jsonData: data) { setSelfID(auto) } + if selfID != nil { return [] } + return currentSenders() + } + + func setSelfID(_ id: String) { + selfID = id + UserDefaults.standard.set(id, forKey: "aiCloneTelegramSelfID") + } + + /// True once the user's own sender identity is known (auto-detected or picked). + func hasSelfIdentity() -> Bool { selfID != nil } + + /// Aggregated senders across all imported personal chats, most-active-first. Used + /// both for the initial disambiguation prompt and the "Change" affordance. + func currentSenders() -> [TelegramSender] { + var merged: [String: TelegramSender] = [:] + for chat in chats where chat.isPersonalChat { + for s in chat.senders { + guard s.senderID != nil else { continue } + let key = s.id + merged[key] = TelegramSender( + senderID: s.senderID, name: s.name ?? merged[key]?.name, + messageCount: (merged[key]?.messageCount ?? 0) + s.messageCount) + } + } + return merged.values.sorted { $0.messageCount > $1.messageCount } + } + + func topContacts(limit: Int) -> [ImportedContact] { + chats.filter(\.isPersonalChat) + .sorted { $0.messages.count > $1.messages.count } + .prefix(limit) + .map { + ImportedContact( + id: "telegram:\($0.id)", + displayName: $0.name ?? $0.senders.first?.name ?? "Unknown", + messageCount: $0.messages.count, platform: "telegram") + } + } + + func messages(for contactID: String, limit: Int = 500) -> [ImportedMessage] { + guard let chat = chats.first(where: { "telegram:\($0.id)" == contactID }) else { return [] } + return chat.messages.suffix(limit).map { + ImportedMessage( + isFromMe: $0.senderID != nil && $0.senderID == selfID, text: $0.text, date: $0.date) + } + } +} diff --git a/desktop/macos/Desktop/Sources/TelegramLoginHarness.swift b/desktop/macos/Desktop/Sources/TelegramLoginHarness.swift new file mode 100644 index 00000000000..198e2994731 --- /dev/null +++ b/desktop/macos/Desktop/Sources/TelegramLoginHarness.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Headless automation actions for the native Telegram (TDLib) login + send flow, +/// registered on the local automation bridge (non-production bundles only). They let +/// an agent drive the real auth state machine turn-by-turn without touching the UI — +/// mirroring how the Rust POC used a control directory. +/// +/// Actions: +/// telegram_login_start — create the client, begin/resume auth +/// telegram_login_status — current auth state ("connecting"/"waitPhone"/…) +/// telegram_login_phone phone=+1… — submit the phone number +/// telegram_login_code code=12345 — submit the login code +/// telegram_login_password password=… — submit the 2-step password (2FA accounts) +/// telegram_send chat_id=… text=… — send a text message (requires ready) +/// telegram_logout — log out and tear the client down +enum TelegramLoginHarness { + + @MainActor + static func register(on registry: DesktopAutomationActionRegistry) { + registry.register( + name: "telegram_login_start", + summary: "Create the Telegram TDLib client and begin (or resume) the auth flow" + ) { _ in + await TelegramSendService.shared.start() + // Give TDLib a beat to emit the first authorization-state update. + try? await Task.sleep(nanoseconds: 800_000_000) + return ["state": Self.label(await TelegramSendService.shared.state())] + } + + registry.register( + name: "telegram_login_status", + summary: "Report the current Telegram authorization state" + ) { _ in + ["state": Self.label(await TelegramSendService.shared.state())] + } + + registry.register( + name: "telegram_login_phone", + summary: "Submit the phone number to Telegram (international format, e.g. +14155550123)", + params: ["phone"] + ) { params in + guard let phone = params["phone"], !phone.isEmpty else { return ["error": "missing 'phone'"] } + await TelegramSendService.shared.submitPhone(phone) + try? await Task.sleep(nanoseconds: 1_500_000_000) + return ["state": Self.label(await TelegramSendService.shared.state())] + } + + registry.register( + name: "telegram_login_code", + summary: "Submit the Telegram login code", + params: ["code"] + ) { params in + guard let code = params["code"], !code.isEmpty else { return ["error": "missing 'code'"] } + await TelegramSendService.shared.submitCode(code) + try? await Task.sleep(nanoseconds: 1_500_000_000) + return ["state": Self.label(await TelegramSendService.shared.state())] + } + + registry.register( + name: "telegram_login_password", + summary: "Submit the Telegram two-step verification password", + params: ["password"] + ) { params in + guard let password = params["password"], !password.isEmpty else { + return ["error": "missing 'password'"] + } + await TelegramSendService.shared.submitPassword(password) + try? await Task.sleep(nanoseconds: 1_500_000_000) + return ["state": Self.label(await TelegramSendService.shared.state())] + } + + registry.register( + name: "telegram_me", + summary: "Return the logged-in Telegram user (id + name) via an authenticated getMe round-trip" + ) { _ in + do { + let me = try await TelegramSendService.shared.me() + return ["id": String(me.id), "name": me.firstName, "last_name": me.lastName] + } catch { + return ["error": error.localizedDescription] + } + } + + registry.register( + name: "telegram_send", + summary: "Send a text message to a Telegram chat id (requires a ready session)", + params: ["chat_id", "text"] + ) { params in + guard let chatIdString = params["chat_id"], let chatId = Int64(chatIdString) else { + return ["error": "missing or invalid 'chat_id'"] + } + guard let text = params["text"], !text.isEmpty else { return ["error": "missing 'text'"] } + do { + try await TelegramSendService.shared.sendMessage(chatId: chatId, text: text) + return ["sent": "true", "chat_id": chatIdString] + } catch { + return ["error": error.localizedDescription] + } + } + + registry.register( + name: "telegram_logout", + summary: "Log out of Telegram and tear down the TDLib client" + ) { _ in + await TelegramSendService.shared.logout() + return ["state": Self.label(await TelegramSendService.shared.state())] + } + } + + private static func label(_ state: TelegramAuthState) -> String { + switch state { + case .connecting: return "connecting" + case .waitPhone: return "waitPhone" + case .waitCode: return "waitCode" + case .waitPassword: return "waitPassword" + case .ready: return "ready" + case .closed: return "closed" + case .error(let message): return "error: \(message)" + } + } +} diff --git a/desktop/macos/Desktop/Sources/TelegramLoginView.swift b/desktop/macos/Desktop/Sources/TelegramLoginView.swift new file mode 100644 index 00000000000..ae434cddc7d --- /dev/null +++ b/desktop/macos/Desktop/Sources/TelegramLoginView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// Minimal Telegram login sheet: phone → code → optional 2-step password, driving +/// `TelegramSendService`'s auth state machine through to `authorizationStateReady`. +/// +/// The service is the source of truth; this view observes `TelegramLoginModel` and +/// pushes user input back into the actor. It intentionally does NOT own any TDLib +/// state itself. +struct TelegramLoginView: View { + @ObservedObject private var model = TelegramLoginModel.shared + @Environment(\.dismiss) private var dismiss + + @State private var phone = "" + @State private var code = "" + @State private var password = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Connect Telegram") + .font(.title2.weight(.semibold)) + + switch model.state { + case .connecting: + row { + ProgressView().controlSize(.small) + Text("Connecting…").foregroundStyle(.secondary) + } + + case .waitPhone: + field( + title: "Phone number", + prompt: "+1 415 555 0123", + text: $phone, + submitLabel: "Send code" + ) { + model.isSubmitting = true + Task { await TelegramSendService.shared.submitPhone(phone) } + } + + case .waitCode: + field( + title: "Login code", + prompt: "12345", + text: $code, + submitLabel: "Verify" + ) { + model.isSubmitting = true + Task { await TelegramSendService.shared.submitCode(code) } + } + + case .waitPassword: + secureField( + title: "Two-step verification password", + text: $password, + submitLabel: "Log in" + ) { + model.isSubmitting = true + Task { await TelegramSendService.shared.submitPassword(password) } + } + + case .ready: + row { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text("Connected").font(.headline) + } + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + + case .closed: + Text("Disconnected.").foregroundStyle(.secondary) + + case .error(let message): + row { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text(message).foregroundStyle(.secondary) + } + Button("Try again") { + Task { await TelegramSendService.shared.start() } + } + } + } + .padding(24) + .frame(width: 380) + .task { await TelegramSendService.shared.start() } + } + + // MARK: - Building blocks + + @ViewBuilder + private func row(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 8) { content() } + } + + @ViewBuilder + private func field( + title: String, prompt: String, text: Binding, submitLabel: String, + action: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.subheadline).foregroundStyle(.secondary) + TextField(prompt, text: text) + .textFieldStyle(.roundedBorder) + .onSubmit(action) + submitButton(submitLabel, text: text.wrappedValue, action: action) + } + } + + @ViewBuilder + private func secureField( + title: String, text: Binding, submitLabel: String, action: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.subheadline).foregroundStyle(.secondary) + SecureField("", text: text) + .textFieldStyle(.roundedBorder) + .onSubmit(action) + submitButton(submitLabel, text: text.wrappedValue, action: action) + } + } + + @ViewBuilder + private func submitButton(_ label: String, text: String, action: @escaping () -> Void) + -> some View + { + Button(action: action) { + if model.isSubmitting { + ProgressView().controlSize(.small) + } else { + Text(label) + } + } + .keyboardShortcut(.defaultAction) + .disabled(model.isSubmitting || text.trimmingCharacters(in: .whitespaces).isEmpty) + } +} diff --git a/desktop/macos/Desktop/Sources/TelegramSendService.swift b/desktop/macos/Desktop/Sources/TelegramSendService.swift new file mode 100644 index 00000000000..00c644a7f4f --- /dev/null +++ b/desktop/macos/Desktop/Sources/TelegramSendService.swift @@ -0,0 +1,391 @@ +import Foundation +import TDLibKit + +// MARK: - Auth state + +/// The public-facing authorization state the login UI drives against. Mirrors the +/// subset of TDLib's `AuthorizationState` machine we actually respond to, collapsing +/// the rest into `.connecting` / `.error`. +enum TelegramAuthState: Equatable, Sendable { + /// No client yet, or a client that is still negotiating parameters / connecting. + case connecting + /// TDLib wants the phone number (international format, e.g. +14155550123). + case waitPhone + /// A login code was sent (SMS / Telegram app) and TDLib wants it back. + case waitCode + /// The account has 2-step verification; TDLib wants the cloud password. + case waitPassword + /// Fully authorized — safe to send and receive messages. + case ready + /// The client was closed (logout or teardown). + case closed + /// A terminal-ish error surfaced by TDLib (login failure, unhandled state, …). + case error(String) +} + +enum TelegramSendError: LocalizedError { + case missingCredentials + case notReady + case clientUnavailable + + var errorDescription: String? { + switch self { + case .missingCredentials: + return "Telegram API credentials are missing from the Keychain." + case .notReady: + return "Telegram is not logged in yet." + case .clientUnavailable: + return "The Telegram client is not available." + } + } +} + +// MARK: - Observable model for SwiftUI + +/// Bridges the actor's auth state onto the main actor so SwiftUI can observe it. +/// The actor owns the truth; this is a thin, published mirror. +@MainActor +final class TelegramLoginModel: ObservableObject { + static let shared = TelegramLoginModel() + @Published private(set) var state: TelegramAuthState = .connecting + /// Set while a request the user just submitted is in flight (disables the button). + @Published var isSubmitting = false + + fileprivate func apply(_ newState: TelegramAuthState) { + state = newState + if case .waitPhone = newState { isSubmitting = false } + if case .waitCode = newState { isSubmitting = false } + if case .waitPassword = newState { isSubmitting = false } + if case .error = newState { isSubmitting = false } + if case .ready = newState { isSubmitting = false } + } +} + +// MARK: - Service + +/// Native Telegram client wrapping TDLibKit's prebuilt TDLib (1.8.65). Owns the +/// client lifecycle, the authorization state machine, message sending, and the +/// realtime update subscription. +/// +/// Ported from a validated Rust `td_json` POC. Notable version-driven differences +/// from that POC's older TDLib: +/// * There is NO `authorizationStateWaitEncryptionKey` state and no +/// `checkDatabaseEncryptionKey` in TDLib 1.8.65 — the database encryption key +/// is passed inline via `setTdlibParameters(databaseEncryptionKey:)`. +/// * `setTdlibParameters` fields are flat (TDLibKit's typed API already reflects this). +/// +/// Security: TDLib's internal logger writes `api_hash` and the phone number in +/// plaintext at its default verbosity. We drop verbosity to 0 (fatal-only) before +/// any secret-bearing request is ever sent — see `silenceLoggingOnce`. +actor TelegramSendService { + static let shared = TelegramSendService() + + private var manager: TDLibClientManager? + private var client: TDLibClient? + private var didSilenceLogging = false + private var currentState: TelegramAuthState = .connecting + + /// Live message subscription installed by `startListening`. TDLib delivers *all* + /// updates through the single client update handler, so we fan out here. + private var onNewMessage: (@Sendable (Int64, Bool, String, Foundation.Date) -> Void)? + + // MARK: Session persistence + // + // TDLib persists its own encrypted session (auth keys, DC, message cache) under + // `database_directory`. Pointing this at a stable Application Support path means a + // relaunch resumes straight to `authorizationStateReady` with no phone/code re-entry. + + private static var sessionRoot: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return base.appendingPathComponent("Omi/tdlib", isDirectory: true) + } + private static var databaseDirectory: URL { sessionRoot.appendingPathComponent("db", isDirectory: true) } + private static var filesDirectory: URL { sessionRoot.appendingPathComponent("files", isDirectory: true) } + + // MARK: Lifecycle + + /// Create the client (if needed) and begin (or resume) the authorization flow. + /// Safe to call repeatedly — a no-op once a client exists. + func start() { + guard client == nil else { return } + + // Ensure the persistent session directories exist up front. + try? FileManager.default.createDirectory( + at: Self.databaseDirectory, withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: Self.filesDirectory, withIntermediateDirectories: true) + + let manager = TDLibClientManager() + // The update handler runs on TDLib's serial per-client queue. It must NOT block, + // so it silences logging once (synchronously, before we ever answer + // WaitTdlibParameters), decodes the update, then hops onto the actor to react. + let client = manager.createClient { [weak self] data, client in + guard let self else { return } + self.silenceLoggingOnce(client) + guard let update = try? client.decoder.decode(Update.self, from: data) else { return } + Task { await self.handle(update) } + } + self.manager = manager + self.client = client + // Belt-and-suspenders: also lower verbosity synchronously here. `createClient` + // only sends a secret-free getOption("version") before this, so nothing sensitive + // can have been logged yet. + silence(client) + } + + /// Tear the client down (used on logout). TDLib flushes the session on `close`. + func logout() async { + guard let client else { return } + onNewMessage = nil + try? await client.logOut() + self.client = nil + self.manager?.closeClients() + self.manager = nil + didSilenceLogging = false + await setState(.closed) + } + + // MARK: Auth flow — inputs from the login UI + + func submitPhone(_ phone: String) async { + guard let client else { await setState(.error("no client")); return } + do { + _ = try await client.setAuthenticationPhoneNumber( + phoneNumber: phone.trimmingCharacters(in: .whitespaces), settings: nil) + } catch { + await setState(.error(Self.describe(error))) + } + } + + func submitCode(_ code: String) async { + guard let client else { await setState(.error("no client")); return } + do { + _ = try await client.checkAuthenticationCode(code: code.trimmingCharacters(in: .whitespaces)) + } catch { + await setState(.error(Self.describe(error))) + } + } + + func submitPassword(_ password: String) async { + guard let client else { await setState(.error("no client")); return } + do { + _ = try await client.checkAuthenticationPassword(password: password) + } catch { + await setState(.error(Self.describe(error))) + } + } + + func state() -> TelegramAuthState { currentState } + + /// The logged-in user. A real authenticated round-trip to Telegram's servers — + /// useful to prove a resumed session is actually usable (not just "ready" locally). + func me() async throws -> User { + guard let client else { throw TelegramSendError.clientUnavailable } + return try await client.getMe() + } + + // MARK: Sending + + /// Send a plain-text message to a chat. `chatId` is TDLib's chat id (for a 1:1 chat + /// this equals the peer's user id and is positive). + func sendMessage(chatId: Int64, text: String) async throws { + guard case .ready = currentState else { throw TelegramSendError.notReady } + guard let client else { throw TelegramSendError.clientUnavailable } + // Ensure TDLib has the chat loaded. On a cold session resume the chat list isn't + // in memory yet, so sendMessage against a raw chat id fails until it's known. For + // a 1:1 chat the id equals the peer's user id, so createPrivateChat loads and + // registers it; otherwise getChat pulls it into the chat list. + if chatId > 0 { + _ = try? await client.createPrivateChat(force: false, userId: chatId) + } else { + _ = try? await client.getChat(chatId: chatId) + } + let content = InputMessageContent.inputMessageText( + InputMessageText( + clearDraft: true, + linkPreviewOptions: nil, + text: FormattedText(entities: [], text: text))) + _ = try await client.sendMessage( + chatId: chatId, + inputMessageContent: content, + options: nil, + replyMarkup: nil, + replyTo: nil, + topicId: nil) + } + + /// Best-effort "typing…" chat action, visible to the peer for ~5 seconds or until a + /// message arrives. Purely cosmetic — never throws; a miss just skips the indicator. + func setTyping(chatId: Int64) async { + guard case .ready = currentState, let client else { return } + if chatId > 0 { + _ = try? await client.createPrivateChat(force: false, userId: chatId) + } + _ = try? await client.sendChatAction( + action: .chatActionTyping, businessConnectionId: nil, chatId: chatId, topicId: nil) + } + + // MARK: Listening + + /// Subscribe to incoming/outgoing messages on 1:1 (private) chats. Mirrors the + /// shape of `IMessageSendService.startListening` for consistency across platforms. + /// The callback fires for both directions; `fromMe` distinguishes them. + func startListening( + onNewMessage: @escaping @Sendable ( + _ chatId: Int64, _ fromMe: Bool, _ text: String, _ date: Foundation.Date) + -> Void + ) { + self.onNewMessage = onNewMessage + } + + func stopListening() { + onNewMessage = nil + } + + // MARK: - Update handling + + private func handle(_ update: Update) async { + switch update { + case .updateAuthorizationState(let wrapper): + await handleAuthState(wrapper.authorizationState) + case .updateNewMessage(let wrapper): + deliver(wrapper.message) + default: + break + } + } + + private func handleAuthState(_ state: AuthorizationState) async { + switch state { + case .authorizationStateWaitTdlibParameters: + await sendTdlibParameters() + case .authorizationStateWaitPhoneNumber: + await setState(.waitPhone) + case .authorizationStateWaitCode: + await setState(.waitCode) + case .authorizationStateWaitPassword: + await setState(.waitPassword) + case .authorizationStateReady: + await setState(.ready) + case .authorizationStateLoggingOut, .authorizationStateClosing: + await setState(.connecting) + case .authorizationStateClosed: + await setState(.closed) + default: + // States we don't drive (email, premium purchase, other-device confirmation, + // registration). Surface them so the UI isn't stuck silently. + await setState(.error("Unsupported Telegram auth step: \(String(describing: state))")) + } + } + + private func sendTdlibParameters() async { + guard let client else { return } + guard let creds = Self.readCredentials() else { + await setState(.error(TelegramSendError.missingCredentials.localizedDescription)) + return + } + do { + _ = try await client.setTdlibParameters( + apiHash: creds.apiHash, + apiId: creds.apiId, + applicationVersion: "omi-desktop", + databaseDirectory: Self.databaseDirectory.path, + databaseEncryptionKey: Data(), // empty key — matches the POC + deviceModel: "Desktop", + filesDirectory: Self.filesDirectory.path, + systemLanguageCode: "en", + systemVersion: "macOS", + useChatInfoDatabase: true, + useFileDatabase: true, + useMessageDatabase: true, + useSecretChats: false, + useTestDc: false) + } catch { + await setState(.error(Self.describe(error))) + } + } + + /// Fan a new message out to the registered listener, restricted to 1:1 chats. + /// In TDLib, private (1:1) chats have a positive `chatId` (it equals the peer's + /// user id); groups/supergroups/channels are negative. Text-only for now. + private func deliver(_ message: Message) { + guard let onNewMessage else { return } + guard message.chatId > 0 else { return } + guard case .messageText(let messageText) = message.content else { return } + let text = messageText.text.text + guard !text.isEmpty else { return } + let date = Foundation.Date(timeIntervalSince1970: TimeInterval(message.date)) + onNewMessage(message.chatId, message.isOutgoing, text, date) + } + + // MARK: - State plumbing + + private func setState(_ newState: TelegramAuthState) async { + currentState = newState + await MainActor.run { TelegramLoginModel.shared.apply(newState) } + } + + // MARK: - Logging safety + + /// TDLib's internal logger dumps request/response JSON — including `api_hash` and + /// the phone number — at its default verbosity. Lower to 0 (fatal-only) before any + /// secret-bearing request is sent. Runs at most once, synchronously. + private nonisolated func silenceLoggingOnce(_ client: TDLibClient) { + // `didSilenceLogging` is actor-isolated; the handler runs off-actor, so we can't + // read it here without hopping. Executing setLogVerbosityLevel is idempotent and + // cheap, so just do it unconditionally on the synchronous path — the actor's + // `silence(client)` in `start()` already covers the guaranteed-first case. + silence(client) + } + + private nonisolated func silence(_ client: TDLibClient) { + _ = try? client.execute(query: DTO(SetLogVerbosityLevel(newVerbosityLevel: 0))) + } + + // MARK: - Credentials + + private struct Credentials { let apiId: Int; let apiHash: String } + + /// Read `api_id` / `api_hash` from the macOS Keychain (generic-password items, + /// scoped to the current user). Never hardcoded, never logged. + private static func readCredentials() -> Credentials? { + guard let idString = keychainValue(service: "me.omi.telegram.tdlib.api_id"), + let apiId = Int(idString), + let apiHash = keychainValue(service: "me.omi.telegram.tdlib.api_hash"), + !apiHash.isEmpty + else { return nil } + return Credentials(apiId: apiId, apiHash: apiHash) + } + + private static func keychainValue(service: String) -> String? { + let user = NSUserName() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + process.arguments = ["find-generic-password", "-a", user, "-s", service, "-w"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let value = String( + data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (value?.isEmpty == false) ? value : nil + } catch { + return nil + } + } + + /// Redact a TDLib error to a short, secret-free string for state/UI. TDLib errors + /// carry only a code + message (no credentials), but keep it terse regardless. + private static func describe(_ error: Swift.Error) -> String { + if let tdError = error as? TDLibKit.Error { + return "\(tdError.message) (\(tdError.code))" + } + return error.localizedDescription + } +} diff --git a/desktop/macos/Desktop/Sources/WhatsAppImportService.swift b/desktop/macos/Desktop/Sources/WhatsAppImportService.swift new file mode 100644 index 00000000000..043950e8dd2 --- /dev/null +++ b/desktop/macos/Desktop/Sources/WhatsAppImportService.swift @@ -0,0 +1,321 @@ +import Foundation + +// MARK: - Parsed models + +struct WhatsAppParsedMessage: Sendable { + let senderName: String? + let text: String + let date: Date +} + +enum WhatsAppImportError: LocalizedError { + case unrecognizedFormat + + var errorDescription: String? { + switch self { + case .unrecognizedFormat: + return "No messages found. Export from WhatsApp with a chat → More → Export chat → " + + "Without media, then pick the .txt file." + } + } +} + +private let whatsAppHeaderPattern = + #"^[\u200E\u200F\uFEFF]*\[?(\d{1,4})[./\-](\d{1,2})[./\-](\d{1,4})(?:,[ \u00A0\u202F]*|[ \u00A0\u202F]+)(\d{1,2}):(\d{2})(?::(\d{2}))?[ \u00A0\u202F]*(?:([AaPp])\.?[ \u00A0\u202F]?[Mm]\.?)?\]?[ \u00A0\u202F]*(?:[-–—][ \u00A0\u202F]+)?(.*)$"# + +private struct WhatsAppRawHeader { + let a: Int + let b: Int + let c: Int + let hour: Int + let minute: Int + let second: Int + let isPM: Bool? + let rest: String +} + +private enum WhatsAppLine { + case header(WhatsAppRawHeader) + case continuation(String) +} + +private enum WhatsAppDateOrder { + case dayFirst, monthFirst, yearFirst +} + +func parseWhatsAppExport(text: String) -> [WhatsAppParsedMessage] { + guard let regex = try? NSRegularExpression(pattern: whatsAppHeaderPattern) else { return [] } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + func stripMarks(_ s: String) -> String { + s.replacingOccurrences(of: "\u{200E}", with: "") + .replacingOccurrences(of: "\u{200F}", with: "") + .trimmingCharacters(in: .whitespaces) + } + + func capture(_ index: Int, _ match: NSTextCheckingResult, in line: String) -> String? { + let nsRange = match.range(at: index) + guard nsRange.location != NSNotFound, let range = Range(nsRange, in: line) else { return nil } + return String(line[range]) + } + + // Pass 1: classify every line as a message header or a continuation. + let rawLines = text.replacingOccurrences(of: "\r\n", with: "\n") + .components(separatedBy: "\n") + + var lines: [WhatsAppLine] = [] + var headers: [WhatsAppRawHeader] = [] + for line in rawLines { + let fullRange = NSRange(line.startIndex..., in: line) + if let match = regex.firstMatch(in: line, range: fullRange), + let aText = capture(1, match, in: line), let a = Int(aText), + let bText = capture(2, match, in: line), let b = Int(bText), + let cText = capture(3, match, in: line), let c = Int(cText), + let hourText = capture(4, match, in: line), let hour = Int(hourText), + let minuteText = capture(5, match, in: line), let minute = Int(minuteText) + { + let second = capture(6, match, in: line).flatMap(Int.init) ?? 0 + let isPM = capture(7, match, in: line).map { $0.lowercased() == "p" } + let rest = capture(8, match, in: line) ?? "" + let header = WhatsAppRawHeader( + a: a, b: b, c: c, hour: hour, minute: minute, second: second, isPM: isPM, rest: rest) + lines.append(.header(header)) + headers.append(header) + } else { + lines.append(.continuation(line)) + } + } + + guard !headers.isEmpty else { return [] } + + func date(from header: WhatsAppRawHeader, order: WhatsAppDateOrder) -> Date? { + let day: Int, month: Int, year: Int + switch order { + case .dayFirst: (day, month, year) = (header.a, header.b, header.c) + case .monthFirst: (month, day, year) = (header.a, header.b, header.c) + case .yearFirst: (year, month, day) = (header.a, header.b, header.c) + } + guard (1...12).contains(month), (1...31).contains(day) else { return nil } + + var hour = header.hour + if let isPM = header.isPM { + if isPM && hour < 12 { hour += 12 } + if !isPM && hour == 12 { hour = 0 } + } + guard (0...23).contains(hour), (0...59).contains(header.minute) else { return nil } + + var components = DateComponents() + components.year = year < 100 ? year + 2000 : year + components.month = month + components.day = day + components.hour = hour + components.minute = header.minute + components.second = header.second + return calendar.date(from: components) + } + + // Pass 2: pick the day/month order. + let order: WhatsAppDateOrder + if headers.contains(where: { $0.a >= 1000 }) { + order = .yearFirst + } else { + let maxA = headers.map(\.a).max() ?? 0 + let maxB = headers.map(\.b).max() ?? 0 + if maxA > 12 && maxB <= 12 { + order = .dayFirst + } else if maxB > 12 && maxA <= 12 { + order = .monthFirst + } else { + func inversions(_ candidate: WhatsAppDateOrder) -> Int { + var count = 0 + var previous: Date? + for header in headers { + guard let d = date(from: header, order: candidate) else { + count += 1 + continue + } + if let p = previous, d < p { count += 1 } + previous = d + } + return count + } + order = inversions(.dayFirst) <= inversions(.monthFirst) ? .dayFirst : .monthFirst + } + } + + func splitSender(_ rest: String) -> (sender: String?, body: String) { + guard let range = rest.range(of: ": ") else { return (nil, rest) } + let sender = String(rest[.. [(name: String, messageCount: Int)] { + var counts: [String: Int] = [:] + var order: [String] = [] + for message in messages { + guard let sender = message.senderName else { continue } + if counts[sender] == nil { order.append(sender) } + counts[sender, default: 0] += 1 + } + return + order + .map { (name: $0, messageCount: counts[$0] ?? 0) } + .sorted { $0.messageCount > $1.messageCount } +} + +func isWhatsAppMediaPlaceholder(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("<") && trimmed.hasSuffix(">") { return true } + let lowered = trimmed.lowercased() + let omittedKinds = [ + "image omitted", "video omitted", "audio omitted", "sticker omitted", + "gif omitted", "document omitted", "contact card omitted", + ] + if omittedKinds.contains(lowered) { return true } + if lowered == "null" { return true } + if lowered.hasPrefix(" [WhatsAppSenderOption] { + var anyProduced = false + for url in urls { + let text = try String(contentsOf: url, encoding: .utf8) + let messages = parseWhatsAppExport(text: text) + guard !messages.isEmpty else { continue } + anyProduced = true + let id = "whatsapp:\(url.lastPathComponent)" + if chatsByID[id] == nil { chatOrder.append(id) } + chatsByID[id] = messages + displayNamesByID[id] = Self.displayName(forFileName: url.lastPathComponent, messages: messages) + } + guard anyProduced else { throw WhatsAppImportError.unrecognizedFormat } + guard selfName == nil else { return [] } + return currentSenderOptions() + } + + func setSelfName(_ name: String) { + selfName = name + UserDefaults.standard.set(name, forKey: "aiCloneWhatsAppSelfName") + } + + /// True once the user's own sender name is known (picked from the import options). + func hasSelfIdentity() -> Bool { selfName != nil } + + /// Aggregated sender candidates across all imported chats, most-active-first. Used + /// both for the initial disambiguation prompt and the "Change" affordance. + func currentSenderOptions() -> [WhatsAppSenderOption] { + var counts: [String: Int] = [:] + var chatsContaining: [String: Set] = [:] + for (chatID, messages) in chatsByID { + for entry in whatsAppSenders(in: messages) { + counts[entry.name, default: 0] += entry.messageCount + chatsContaining[entry.name, default: []].insert(chatID) + } + } + return counts.keys + .sorted { (counts[$0] ?? 0) > (counts[$1] ?? 0) } + .map { name in + WhatsAppSenderOption( + name: name, messageCount: counts[name] ?? 0, + appearsInEveryChat: chatsContaining[name]?.count == chatsByID.count) + } + } + + func topContacts(limit: Int) -> [ImportedContact] { + chatOrder + .compactMap { id -> ImportedContact? in + guard let messages = chatsByID[id] else { return nil } + return ImportedContact( + id: id, displayName: displayNamesByID[id] ?? id, + messageCount: messages.count, platform: "whatsapp") + } + .sorted { $0.messageCount > $1.messageCount } + .prefix(limit) + .map { $0 } + } + + func messages(for contactID: String, limit: Int = 500) -> [ImportedMessage] { + guard let raw = chatsByID[contactID] else { return [] } + let selfName = selfName + return + raw + .filter { $0.senderName != nil && !isWhatsAppMediaPlaceholder($0.text) } + .suffix(limit) + .map { ImportedMessage(isFromMe: $0.senderName == selfName, text: $0.text, date: $0.date) } + } + + /// `WhatsApp Chat with X.txt` → `X`. Falls back to the most frequent non-system sender + /// (e.g. for `_chat.txt`, the generic name inside an exported zip). + private static func displayName(forFileName fileName: String, messages: [WhatsAppParsedMessage]) + -> String + { + let base = (fileName as NSString).deletingPathExtension + let prefix = "WhatsApp Chat with " + if base.hasPrefix(prefix) { + return String(base.dropFirst(prefix.count)) + } + return whatsAppSenders(in: messages).first?.name ?? base + } +} diff --git a/desktop/macos/Desktop/Sources/WhatsAppSendService.swift b/desktop/macos/Desktop/Sources/WhatsAppSendService.swift new file mode 100644 index 00000000000..b9b31f8f0b1 --- /dev/null +++ b/desktop/macos/Desktop/Sources/WhatsAppSendService.swift @@ -0,0 +1,582 @@ +import AppKit +import Foundation + +// MARK: - Link state + +/// The public-facing linking state the WhatsApp UI drives against. Mirrors the sidecar's +/// state machine (`unlinked | connecting | waiting_qr | linked | logged_out`), plus local +/// process states. +enum WhatsAppLinkState: Equatable, Sendable { + /// Sidecar not running (nothing spawned yet, or it exited). + case stopped + /// Sidecar spawned, waiting for its ready handshake. + case starting + /// Sidecar up, no saved session, no link attempt in progress. + case unlinked + /// Negotiating with WhatsApp servers (fresh link or session resume). + case connecting + /// A QR code is ready to scan (base64 PNG data URL). + case waitingScan(qrDataUrl: String) + /// Linked and usable. `phone` is the account's number (digits only). + case linked(phone: String) + /// The phone unlinked this device — the saved session was cleared. + case loggedOut + /// A terminal-ish local failure (node missing, sidecar crashed, …). + case error(String) + + var isLinked: Bool { + if case .linked = self { return true } + return false + } +} + +enum WhatsAppSendError: LocalizedError { + case notLinked + case sidecarUnavailable(String) + case sendFailed(String) + case contactNotResolvable(String) + + var errorDescription: String? { + switch self { + case .notLinked: + return "WhatsApp isn't linked yet — scan the QR code from the AI Clone page first." + case .sidecarUnavailable(let detail): + return "The WhatsApp connector couldn't start: \(detail)" + case .sendFailed(let detail): + return "Couldn't send the WhatsApp message: \(detail)" + case .contactNotResolvable(let name): + return + "Couldn't match \"\(name)\" to a WhatsApp number. Ask them to message you once, or make sure the chat name matches a contact." + } + } +} + +// MARK: - Observable model for SwiftUI + +/// Bridges the actor's link state onto the main actor so SwiftUI can observe it. +/// The actor owns the truth; this is a thin, published mirror (same pattern as +/// `TelegramLoginModel`). +@MainActor +final class WhatsAppLinkModel: ObservableObject { + static let shared = WhatsAppLinkModel() + @Published private(set) var state: WhatsAppLinkState = .stopped + + fileprivate func apply(_ newState: WhatsAppLinkState) { + state = newState + } +} + +// MARK: - Service + +/// WhatsApp send + live-receive service — the WhatsApp counterpart to +/// `IMessageSendService` / `TelegramSendService`, so the AI Clone send-mode layer treats +/// all three platforms uniformly: +/// * `send(to:text:)` +/// * `startListening(onNewMessage:)` / `stopListening()` +/// +/// Unlike the other two, WhatsApp has no local database or official API for personal +/// accounts. This talks to a local-only Node sidecar (`whatsapp-sidecar/`) running Baileys, +/// which joins the account via WhatsApp's own "Linked Devices" (the WhatsApp Web mechanism). +/// That is an UNOFFICIAL connection method — the app treats it with extra caution: sends +/// only ever happen through the send-mode coordinator's kill-switch/mode gates, and enabling +/// Autonomous for a WhatsApp contact additionally requires a one-time explicit risk +/// acknowledgment (see `AICloneSendModeService`). +/// +/// The sidecar is spawned on demand (mirroring `AgentRuntimeProcess`'s node lifecycle), +/// bound to 127.0.0.1 with a per-spawn bearer token, and tethered to this process — it +/// exits when the app dies, so no orphaned Node processes. +actor WhatsAppSendService { + static let shared = WhatsAppSendService() + + /// Poll cadence for new-message detection while listening. + private static let eventPollInterval: TimeInterval = 2.5 + + // MARK: Process + HTTP state + + private var process: Process? + private var stdinPipe: Pipe? + private var port: Int = WhatsAppSendService.defaultPort + private var token = "" + private var readySignaled = false + private var currentState: WhatsAppLinkState = .stopped + + private var listenerTask: Task? + private var onNewMessage: + (@Sendable (_ phone: String, _ fromMe: Bool, _ text: String, _ date: Date, _ senderName: String?) -> Void)? + /// Highest event `seq` consumed. Seeded to the sidecar's latest on listen start so we + /// never replay events buffered before listening began. + private var eventCursor: Int64 = -1 + + /// Synchronous handle for `applicationWillTerminate` (can't await an actor there). + private static let quitTerminator = ProcessTerminator() + + private static var defaultPort: Int { + if let raw = ProcessInfo.processInfo.environment["OMI_WA_PORT"], let value = Int(raw) { + return value + } + return 47790 + } + + /// Session persists here so relinking isn't needed after the first QR scan. + static var sessionDirectory: URL { + let base = + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return base.appendingPathComponent("Omi/whatsapp-session", isDirectory: true) + } + + // MARK: - Public state + + func state() -> WhatsAppLinkState { currentState } + + /// True when a previous session exists on disk — the sidecar will resume it without a QR. + nonisolated static func hasSavedSession() -> Bool { + FileManager.default.fileExists( + atPath: sessionDirectory.appendingPathComponent("creds.json").path) + } + + // MARK: - Linking + + /// Ensure the sidecar is running and begin (or resume) linking. Returns the resulting + /// state; the UI keeps polling `refreshStatus()` afterward for QR/linked transitions. + @discardableResult + func startLinking() async -> WhatsAppLinkState { + do { + try await ensureSidecar() + _ = try await request(path: "/link/start", method: "POST") + return await refreshStatus() + } catch { + let message = (error as? WhatsAppSendError)?.localizedDescription ?? error.localizedDescription + await setState(.error(message)) + return currentState + } + } + + /// One status poll against the sidecar; updates and returns the published state. + @discardableResult + func refreshStatus() async -> WhatsAppLinkState { + guard process?.isRunning == true else { + if case .error = currentState { return currentState } + await setState(.stopped) + return currentState + } + do { + let json = try await request(path: "/link/status", method: "GET") + await setState(Self.linkState(fromStatusJson: json)) + } catch { + await setState(.error("Lost contact with the WhatsApp connector.")) + } + return currentState + } + + /// Unlink: tells WhatsApp to log this device out and clears the saved session. + func logout() async { + guard process?.isRunning == true else { return } + _ = try? await request(path: "/logout", method: "POST") + _ = await refreshStatus() + } + + /// Map the sidecar's `/link/status` JSON onto the Swift state. Pure for testability. + nonisolated static func linkState(fromStatusJson json: [String: Any]) -> WhatsAppLinkState { + switch json["state"] as? String { + case "linked": + return .linked(phone: json["phone"] as? String ?? "") + case "waiting_qr": + if let qr = json["qrDataUrl"] as? String, !qr.isEmpty { + return .waitingScan(qrDataUrl: qr) + } + return .connecting + case "connecting": + return .connecting + case "logged_out": + return .loggedOut + case "unlinked": + return .unlinked + default: + return .error("Unknown WhatsApp connector state") + } + } + + // MARK: - Sending + + /// Send a plain-text message. `to` is a phone number (digits, `+`/spaces tolerated) or a + /// full `…@s.whatsapp.net` JID. Requires a linked session; throws otherwise. + func send(to: String, text: String) async throws { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw WhatsAppSendError.sendFailed("empty message") } + try await ensureSidecar() + guard (await refreshStatus()).isLinked else { throw WhatsAppSendError.notLinked } + do { + _ = try await request(path: "/send", method: "POST", body: ["to": to, "text": trimmed]) + } catch let error as SidecarHTTPError { + throw WhatsAppSendError.sendFailed(error.message) + } + } + + /// Best-effort read receipt ("blue ticks") for the latest incoming message from this + /// peer. Purely cosmetic — never throws and never spawns the sidecar; a miss is a no-op. + func markRead(to: String) async { + guard process?.isRunning == true, currentState.isLinked else { return } + _ = try? await request(path: "/read", method: "POST", body: ["to": to]) + } + + /// Best-effort "typing…" presence shown to the peer. Same non-goals as `markRead`: + /// cosmetic only, never throws, never spawns the sidecar. + func setComposing(to: String, _ composing: Bool) async { + guard process?.isRunning == true, currentState.isLinked else { return } + _ = try? await request( + path: "/presence", method: "POST", + body: ["to": to, "state": composing ? "composing" : "paused"]) + } + + /// Resolve a contact display name to a phone number using the linked account's synced + /// contacts. Returns nil when there is no unambiguous match. + func resolvePhone(forName name: String) async -> String? { + guard process?.isRunning == true, currentState.isLinked else { return nil } + guard + let encoded = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let json = try? await request(path: "/resolve?name=\(encoded)", method: "GET"), + let phone = json["phone"] as? String, !phone.isEmpty + else { return nil } + return phone + } + + // MARK: - Listening + + /// Begin polling the sidecar for new 1:1 messages. The callback fires for both directions + /// (`fromMe` distinguishes them), mirroring the other platforms; `senderName` carries the + /// sender's WhatsApp push-name so the send-mode layer can match imported contacts by name. + func startListening( + onNewMessage: @escaping @Sendable ( + _ phone: String, _ fromMe: Bool, _ text: String, _ date: Date, _ senderName: String? + ) -> Void + ) { + self.onNewMessage = onNewMessage + guard listenerTask == nil else { return } + eventCursor = -1 + listenerTask = Task { [weak self] in + while !Task.isCancelled { + await self?.pollEvents() + try? await Task.sleep(nanoseconds: UInt64(Self.eventPollInterval * 1_000_000_000)) + } + } + } + + func stopListening() { + listenerTask?.cancel() + listenerTask = nil + onNewMessage = nil + eventCursor = -1 + } + + private func pollEvents() async { + guard onNewMessage != nil, process?.isRunning == true else { return } + // First pass anchors the cursor to the sidecar's latest seq — never replay buffered + // history from before listening started (same baseline pattern as IMessageSendService). + let sinceParam = max(0, eventCursor) + guard let json = try? await request(path: "/events?since=\(sinceParam)", method: "GET"), + let latest = json["latest"] as? Int64 ?? (json["latest"] as? Int).map(Int64.init) + else { return } + if eventCursor < 0 { + eventCursor = latest + return + } + eventCursor = max(eventCursor, latest) + guard let events = json["events"] as? [[String: Any]], !events.isEmpty, + let onNewMessage + else { return } + for event in events { + guard let phone = event["phone"] as? String, !phone.isEmpty, + let text = event["text"] as? String, !text.isEmpty + else { continue } + let fromMe = event["fromMe"] as? Bool ?? false + let timestamp = (event["timestamp"] as? Double) ?? Double(event["timestamp"] as? Int ?? 0) + let senderName = event["senderName"] as? String + onNewMessage(phone, fromMe, text, Date(timeIntervalSince1970: timestamp), senderName) + } + } + + // MARK: - Sidecar process lifecycle + + /// Spawn the sidecar if it isn't running. Mirrors `AgentRuntimeProcess`: find node, find + /// the script, pipe stdin (the tether that kills the child if we die), wait for the ready + /// line on stdout. + func ensureSidecar() async throws { + if process?.isRunning == true { return } + process = nil + readySignaled = false + await setState(.starting) + + guard let nodePath = Self.findNodeBinary() else { + await setState(.error("Node.js not found — install node (brew install node).")) + throw WhatsAppSendError.sidecarUnavailable("node binary not found") + } + guard let scriptPath = Self.findSidecarScript() else { + await setState(.error("WhatsApp connector files are missing from this build.")) + throw WhatsAppSendError.sidecarUnavailable("sidecar script not found") + } + let sidecarRoot = ((scriptPath as NSString).deletingLastPathComponent as NSString) + .deletingLastPathComponent + let modulesPath = (sidecarRoot as NSString).appendingPathComponent("node_modules/@whiskeysockets") + guard FileManager.default.fileExists(atPath: modulesPath) else { + await setState(.error("WhatsApp connector dependencies missing (run npm install in whatsapp-sidecar).")) + throw WhatsAppSendError.sidecarUnavailable("node_modules missing at \(sidecarRoot)") + } + + token = UUID().uuidString + let proc = Process() + proc.executableURL = URL(fileURLWithPath: nodePath) + proc.arguments = ["--max-old-space-size=192", scriptPath] + proc.currentDirectoryURL = URL(fileURLWithPath: sidecarRoot) + + var env = ProcessInfo.processInfo.environment + env["NODE_NO_WARNINGS"] = "1" + env["OMI_WA_PORT"] = String(port) + env["OMI_WA_TOKEN"] = token + env["OMI_WA_SESSION_DIR"] = Self.sessionDirectory.path + env["OMI_WA_PARENT_PID"] = String(ProcessInfo.processInfo.processIdentifier) + proc.environment = env + + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + proc.standardInput = stdin + proc.standardOutput = stdout + proc.standardError = stderr + + stderr.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty, let text = String(data: data, encoding: .utf8) { + log("WhatsAppSendService sidecar stderr: \(text.trimmingCharacters(in: .whitespacesAndNewlines).prefix(400))") + } + } + + proc.terminationHandler = { [weak self] terminated in + log("WhatsAppSendService: sidecar exited (code=\(terminated.terminationStatus))") + Task { await self?.handleSidecarExit() } + } + + do { + try proc.run() + } catch { + await setState(.error("Couldn't start the WhatsApp connector: \(error.localizedDescription)")) + throw WhatsAppSendError.sidecarUnavailable(error.localizedDescription) + } + process = proc + stdinPipe = stdin + Self.quitTerminator.set(proc) + log("WhatsAppSendService: sidecar started (pid=\(proc.processIdentifier), port=\(port))") + + // Wait for the single-line ready handshake on stdout (or early exit). + let ready = await Self.waitForReadyLine(handle: stdout.fileHandleForReading, timeout: 15) + stdout.fileHandleForReading.readabilityHandler = nil + guard ready, proc.isRunning else { + await stopSidecar() + await setState(.error("The WhatsApp connector didn't start correctly.")) + throw WhatsAppSendError.sidecarUnavailable("ready handshake timed out") + } + readySignaled = true + _ = await refreshStatus() + } + + /// Terminate the sidecar (listening stops; the saved session on disk is untouched). + func stopSidecar() async { + stopListening() + guard let proc = process else { return } + try? stdinPipe?.fileHandleForWriting.close() + proc.terminate() + let start = Date() + while proc.isRunning && Date().timeIntervalSince(start) < 2.0 { + try? await Task.sleep(nanoseconds: 50_000_000) + } + if proc.isRunning { + kill(proc.processIdentifier, SIGKILL) + } + process = nil + stdinPipe = nil + Self.quitTerminator.set(nil) + await setState(.stopped) + } + + private func handleSidecarExit() async { + process = nil + stdinPipe = nil + Self.quitTerminator.set(nil) + // Startup failures set their own `.error` state; only a post-ready exit becomes .stopped. + guard readySignaled else { return } + if case .error = currentState { return } + await setState(.stopped) + } + + /// Best-effort synchronous kill for `applicationWillTerminate`. The stdin tether and the + /// sidecar's parent-PID watchdog make this redundant, but exiting cleanly is politer. + nonisolated static func terminateSidecarOnQuit() { + quitTerminator.terminate() + } + + private static func waitForReadyLine(handle: FileHandle, timeout: TimeInterval) async -> Bool { + await withCheckedContinuation { continuation in + let box = ContinuationBox(continuation) + handle.readabilityHandler = { fileHandle in + let data = fileHandle.availableData + if data.isEmpty { + fileHandle.readabilityHandler = nil + box.resume(false) + return + } + guard let text = box.appendAndSnapshot(data) else { return } + if text.contains("\"type\":\"ready\"") { + fileHandle.readabilityHandler = nil + box.resume(true) + } else if text.contains("\"type\":\"error\"") { + fileHandle.readabilityHandler = nil + log("WhatsAppSendService: sidecar reported startup error: \(text.prefix(300))") + box.resume(false) + } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + box.resume(false) + } + } + } + + /// One-shot continuation guard: the ready handshake can be resolved by the reader, the + /// timeout, or an early EOF — whichever fires first wins, the rest are no-ops. + private final class ContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var buffer = Data() + init(_ continuation: CheckedContinuation) { self.continuation = continuation } + func resume(_ value: Bool) { + lock.lock() + let taken = continuation + continuation = nil + lock.unlock() + taken?.resume(returning: value) + } + /// Accumulate stdout bytes and return the buffered text so far (nil if not valid UTF-8). + func appendAndSnapshot(_ data: Data) -> String? { + lock.lock() + buffer.append(data) + let snapshot = String(data: buffer, encoding: .utf8) + lock.unlock() + return snapshot + } + } + + /// Lock-guarded Process holder so `applicationWillTerminate` (synchronous, non-actor) + /// can terminate the sidecar without hopping onto the actor. + private final class ProcessTerminator: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + func set(_ proc: Process?) { + lock.lock() + process = proc + lock.unlock() + } + func terminate() { + lock.lock() + let proc = process + process = nil + lock.unlock() + if let proc, proc.isRunning { proc.terminate() } + } + } + + // MARK: - HTTP plumbing + + private struct SidecarHTTPError: Error { + let status: Int + let message: String + } + + private func request( + path: String, method: String, body: [String: Any]? = nil + ) async throws -> [String: Any] { + var urlRequest = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!) + urlRequest.httpMethod = method + urlRequest.timeoutInterval = 20 + urlRequest.setValue(token, forHTTPHeaderField: "x-omi-token") + if let body { + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let (data, response) = try await URLSession.shared.data(for: urlRequest) + let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw SidecarHTTPError( + status: status, message: json["error"] as? String ?? "HTTP \(status)") + } + return json + } + + // MARK: - State plumbing + + private func setState(_ newState: WhatsAppLinkState) async { + currentState = newState + await MainActor.run { WhatsAppLinkModel.shared.apply(newState) } + } + + // MARK: - Binary/script discovery (mirrors AgentRuntimeProcess) + + private static func findNodeBinary() -> String? { + let bundledNode = Bundle.resourceBundle.path(forResource: "node", ofType: nil) + if let bundledNode, FileManager.default.isExecutableFile(atPath: bundledNode) { + return bundledNode + } + let candidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"] + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + return path + } + let home = FileManager.default.homeDirectoryForCurrentUser.path + let nvmDir = (home as NSString).appendingPathComponent(".nvm/versions/node") + if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmDir) { + for version in versions.sorted(by: { $0.compare($1, options: .numeric) == .orderedDescending }) { + let nodePath = (nvmDir as NSString).appendingPathComponent("\(version)/bin/node") + if FileManager.default.isExecutableFile(atPath: nodePath) { + return nodePath + } + } + } + return nil + } + + private static func findSidecarScript() -> String? { + let relative = "whatsapp-sidecar/src/index.js" + if let bundlePath = Bundle.main.resourcePath { + let bundled = (bundlePath as NSString).appendingPathComponent(relative) + if FileManager.default.fileExists(atPath: bundled) { + return bundled + } + } + if let execDir = Bundle.main.executableURL?.deletingLastPathComponent() { + let devPaths = [ + execDir.appendingPathComponent("../../../\(relative)").path, + execDir.appendingPathComponent("../../../../\(relative)").path, + ] + for path in devPaths { + let resolved = (path as NSString).standardizingPath + if FileManager.default.fileExists(atPath: resolved) { + return resolved + } + } + } + let cwd = FileManager.default.currentDirectoryPath + for candidate in [relative, "desktop/macos/\(relative)", "../desktop/macos/\(relative)"] { + let resolved = ((cwd as NSString).appendingPathComponent(candidate) as NSString).standardizingPath + if FileManager.default.fileExists(atPath: resolved) { + return resolved + } + } + // Dev fallback: the app runs from /Applications but the repo lives in the home dir. + let home = FileManager.default.homeDirectoryForCurrentUser.path + let repoPath = (home as NSString).appendingPathComponent("omi/desktop/macos/\(relative)") + if FileManager.default.fileExists(atPath: repoPath) { + return repoPath + } + return nil + } +} diff --git a/desktop/macos/Desktop/Tests/AICloneArchitectureTests.swift b/desktop/macos/Desktop/Tests/AICloneArchitectureTests.swift new file mode 100644 index 00000000000..5400d36c8e5 --- /dev/null +++ b/desktop/macos/Desktop/Tests/AICloneArchitectureTests.swift @@ -0,0 +1,176 @@ +import XCTest + +@testable import Omi_Computer + +final class AICloneArchitectureTests: XCTestCase { + + // MARK: - Candidate JSON parsing + + func testParseCandidatesWellFormed() { + let text = #"{"candidates": [["nah", "nah fr"], ["lol what"], ["bro 😭", "stop"]]}"# + let parsed = AIClonePersonaService.parseCandidates(from: text) + XCTAssertEqual(parsed?.count, 3) + XCTAssertEqual(parsed?[0], ["nah", "nah fr"]) + XCTAssertEqual(parsed?[2], ["bro 😭", "stop"]) + } + + func testParseCandidatesWithFencesAndTrailingProse() { + let text = """ + ```json + {"candidates": [["yeah alr"]]} + ``` + Hope that helps! + """ + let parsed = AIClonePersonaService.parseCandidates(from: text) + XCTAssertEqual(parsed, [["yeah alr"]]) + } + + func testParseCandidatesFlatArrayTolerated() { + let text = #"{"candidates": ["one", "two"]}"# + XCTAssertEqual(AIClonePersonaService.parseCandidates(from: text), [["one", "two"]]) + } + + func testLenientExtractionFromMalformedJSON() { + // Unterminated final array — JSONSerialization fails, lenient path must still + // recover the first candidate's bubbles and never return JSON syntax as content. + let text = #"{"candidates": [["bro 💔", "is it wraps"], ["so she a bop", "ts makes"# + XCTAssertNil(AIClonePersonaService.parseCandidates(from: text)) + let extracted = AIClonePersonaService.extractFirstCandidateStrings(from: text) + XCTAssertEqual(extracted, ["bro 💔", "is it wraps"]) + } + + func testLenientExtractionHandlesEscapes() { + let text = #"{"candidates": [["she said \"no\"", "line\nbreak"]]}"# + let extracted = AIClonePersonaService.extractFirstCandidateStrings(from: text) + XCTAssertEqual(extracted, ["she said \"no\"", "line\nbreak"]) + } + + func testSanitizeBubblesDropsPlaceholdersAndSplitsNewlines() { + let bubbles = AIClonePersonaService.sanitizeBubbles([ + "first\nsecond", " ", "[attachment]", "third", + ]) + XCTAssertEqual(bubbles, ["first", "second", "third"]) + } + + // MARK: - Style features + + private func message(_ text: String, fromMe: Bool, minutesAgo: Double) -> ImportedMessage { + ImportedMessage(isFromMe: fromMe, text: text, date: Date(timeIntervalSinceNow: -minutesAgo * 60)) + } + + private func lowercaseBurstyHistory() -> [ImportedMessage] { + // Oldest-first: contact asks, user replies in 2-bubble lowercase bursts, no periods. + var messages: [ImportedMessage] = [] + for i in 0..<30 { + let t = Double(300 - i * 10) + messages.append(message("question \(i)", fromMe: false, minutesAgo: t + 2)) + messages.append(message("nah bro", fromMe: true, minutesAgo: t + 1)) + messages.append(message("ts crazy fr", fromMe: true, minutesAgo: t)) + } + return messages + } + + func testExtractMeasuresBurstsAndCasing() { + let features = AICloneStyleAnalyzer.extract(from: lowercaseBurstyHistory()) + XCTAssertEqual(features.sampleCount, 60) + XCTAssertGreaterThan(features.burstShare[2], 0.9, "every reply turn is a 2-bubble burst") + XCTAssertGreaterThan(features.lowercaseStartRate, 0.9) + XCTAssertEqual(features.terminalPeriodRate, 0, accuracy: 0.001) + } + + func testStyleScorePrefersInStyleCandidate(){ + let features = AICloneStyleAnalyzer.extract(from: lowercaseBurstyHistory()) + let inStyle = AICloneStyleAnalyzer.styleScore(bubbles: ["nah fr", "ts wild"], features: features) + let offStyle = AICloneStyleAnalyzer.styleScore( + bubbles: ["That sounds like a really interesting opportunity, and I think we should definitely consider all the angles before committing to anything."], + features: features) + XCTAssertGreaterThan(inStyle, offStyle) + } + + // MARK: - Preview bubble rendering + + func testReplySplitsIntoSeparatePreviewBubbles() { + XCTAssertEqual( + AICloneReplyPresentation.bubbles(from: "nah\njs forgot abt it\nu done it"), + ["nah", "js forgot abt it", "u done it"]) + XCTAssertEqual(AICloneReplyPresentation.bubbles(from: "single"), ["single"]) + XCTAssertEqual(AICloneReplyPresentation.bubbles(from: "a\n\n \nb"), ["a", "b"]) + } + + // MARK: - Live chat: latest incoming burst + + func testLatestIncomingBurstJoinsConsecutiveIncomingAndTakesContext() { + let turns: [(isFromMe: Bool, text: String)] = [ + (true, "yo"), + (false, "hey"), + (true, "what's up"), + (false, "did you see the game"), + (false, "insane ending"), + ] + let burst = AICloneLiveChat.latestIncomingBurst(in: turns) + XCTAssertEqual(burst?.incoming, "did you see the game\ninsane ending") + XCTAssertEqual(burst?.context.map(\.text), ["yo", "hey", "what's up"]) + XCTAssertEqual(burst?.context.map(\.isFromMe), [true, false, true]) + } + + func testLatestIncomingBurstIgnoresTrailingFromMeMessages() { + // My messages after the last incoming burst: the burst still targets that incoming. + let turns: [(isFromMe: Bool, text: String)] = [ + (false, "you free tmrw"), + (true, "let me check"), + ] + let burst = AICloneLiveChat.latestIncomingBurst(in: turns) + XCTAssertEqual(burst?.incoming, "you free tmrw") + XCTAssertEqual(burst?.context.count, 0) + } + + func testLatestIncomingBurstNilWithoutIncoming() { + XCTAssertNil(AICloneLiveChat.latestIncomingBurst(in: [(true, "hello?"), (true, "u there")])) + XCTAssertNil(AICloneLiveChat.latestIncomingBurst(in: [])) + } + + func testLatestIncomingBurstCapsContextAtEightTurns() { + var turns: [(isFromMe: Bool, text: String)] = [] + for i in 0..<20 { + turns.append((i % 2 == 1, "turn \(i)")) + } + turns.append((false, "newest")) + let burst = AICloneLiveChat.latestIncomingBurst(in: turns) + XCTAssertEqual(burst?.incoming, "newest") + XCTAssertEqual(burst?.context.count, 8) + XCTAssertEqual(burst?.context.last?.text, "turn 19") + } + + // MARK: - Pair extraction (session gap) + + func testBuildPairsSkipsRepliesAcrossLongGaps() { + let now = Date() + let messages: [ImportedMessage] = [ + ImportedMessage(isFromMe: false, text: "you up?", date: now.addingTimeInterval(-10 * 3600)), + // 9 hours later — a new conversation started by me, NOT a reply. + ImportedMessage(isFromMe: true, text: "yo", date: now.addingTimeInterval(-3600)), + ImportedMessage(isFromMe: false, text: "what did she say", date: now.addingTimeInterval(-1800)), + ImportedMessage(isFromMe: true, text: "nothing yet", date: now.addingTimeInterval(-1700)), + ] + let pairs = AICloneBacktestService.buildPairs(from: messages) + XCTAssertEqual(pairs.count, 1) + XCTAssertEqual(pairs.first?.contactMessage, "what did she say") + XCTAssertEqual(pairs.first?.actualReply, "nothing yet") + } + + func testBuildPairsJoinsBurstsAndKeepsContext() { + let now = Date() + let messages: [ImportedMessage] = [ + ImportedMessage(isFromMe: true, text: "earlier", date: now.addingTimeInterval(-500)), + ImportedMessage(isFromMe: false, text: "one", date: now.addingTimeInterval(-400)), + ImportedMessage(isFromMe: false, text: "two", date: now.addingTimeInterval(-350)), + ImportedMessage(isFromMe: true, text: "reply a", date: now.addingTimeInterval(-300)), + ImportedMessage(isFromMe: true, text: "reply b", date: now.addingTimeInterval(-250)), + ] + let pairs = AICloneBacktestService.buildPairs(from: messages) + XCTAssertEqual(pairs.count, 1) + XCTAssertEqual(pairs.first?.contactMessage, "one\ntwo") + XCTAssertEqual(pairs.first?.actualReply, "reply a\nreply b") + XCTAssertEqual(pairs.first?.context.map(\.text), ["earlier"]) + } +} diff --git a/desktop/macos/Desktop/Tests/AICloneContextTests.swift b/desktop/macos/Desktop/Tests/AICloneContextTests.swift new file mode 100644 index 00000000000..0f785b3e9db --- /dev/null +++ b/desktop/macos/Desktop/Tests/AICloneContextTests.swift @@ -0,0 +1,159 @@ +import XCTest + +@testable import Omi_Computer + +final class AICloneContextTests: XCTestCase { + + // MARK: - Scheduling detection + + func testSchedulingPositives() { + let positives = [ + "hey you free this week?", + "wanna grab lunch tomorrow", + "are you AVAILABLE friday", + "what time works for you", + "can we meet at 3pm", + "lets do 10:30", + "down to hang this weekend?", + "coffee next week?", + "when r u around", + ] + for message in positives { + XCTAssertTrue( + AICloneContextService.isSchedulingRelated(message), "expected scheduling: \(message)") + } + } + + func testSchedulingNegatives() { + let negatives = [ + "lol did you see the game", + "that movie was insane", + "freedom of speech is wild", // "free" embedded in "freedom" must not match + "bro the meetings at work are killing me hahahaha jk", // "meetings" embedded? no — but "meeting" is a word-prefix inside "meetings" + "ok", + "happy birthday!!", + ] + // "meetings" contains "meeting" followed by a letter 's' → boundary check rejects it, + // but "meetings at work" arguably IS scheduling-adjacent; we only assert the clear cases. + for message in [negatives[0], negatives[1], negatives[2], negatives[4], negatives[5]] { + XCTAssertFalse( + AICloneContextService.isSchedulingRelated(message), "expected non-scheduling: \(message)") + } + } + + func testSchedulingWordBoundary() { + XCTAssertFalse(AICloneContextService.isSchedulingRelated("carefree vibes only")) + XCTAssertTrue(AICloneContextService.isSchedulingRelated("u free?")) + } + + // MARK: - Memory block rendering + + func testMemoryBlockCapsAndTruncates() { + let long = String(repeating: "x", count: 500) + let contents = (1...20).map { "fact \($0)" } + [long] + let block = AICloneContextService.renderMemoryBlock(contents) + XCTAssertNotNil(block) + let bulletCount = block!.components(separatedBy: "\n").filter { $0.hasPrefix("- ") }.count + XCTAssertEqual(bulletCount, AICloneContextService.maxMemories) + XCTAssertFalse(block!.contains(long)) // over-long fact would have been truncated (or capped out) + } + + func testMemoryBlockEmptyReturnsNil() { + XCTAssertNil(AICloneContextService.renderMemoryBlock([])) + XCTAssertNil(AICloneContextService.renderMemoryBlock(["", " "])) + } + + func testMemoryBlockGroundingRules() { + let block = AICloneContextService.renderMemoryBlock(["The user works at Acme"]) + XCTAssertNotNil(block) + XCTAssertTrue(block!.contains("never volunteer")) + } + + // MARK: - Calendar block rendering + + private func event( + _ summary: String, start: String, end: String = "", allDay: Bool = false + ) -> CalendarEvent { + CalendarEvent( + id: UUID().uuidString, summary: summary, startTime: start, endTime: end, + attendees: [], location: "", description: "", isAllDay: allDay) + } + + func testCalendarBlockFiltersWindowAndSorts() { + let formatter = ISO8601DateFormatter() + let now = formatter.date(from: "2026-07-03T12:00:00Z")! + let events = [ + event("Future dinner", start: "2026-07-06T19:00:00Z", end: "2026-07-06T21:00:00Z"), + event("Past standup", start: "2026-07-01T10:00:00Z", end: "2026-07-01T10:30:00Z"), + event("Too far out", start: "2026-08-30T10:00:00Z", end: "2026-08-30T11:00:00Z"), + event("Tomorrow call", start: "2026-07-04T15:00:00Z", end: "2026-07-04T15:30:00Z"), + ] + let block = AICloneContextService.renderCalendarBlock(events: events, now: now) + XCTAssertNotNil(block) + XCTAssertTrue(block!.contains("Future dinner")) + XCTAssertTrue(block!.contains("Tomorrow call")) + XCTAssertFalse(block!.contains("Past standup")) + XCTAssertFalse(block!.contains("Too far out")) + // Sorted ascending: tomorrow's call listed before the later dinner. + let callIndex = block!.range(of: "Tomorrow call")!.lowerBound + let dinnerIndex = block!.range(of: "Future dinner")!.lowerBound + XCTAssertLessThan(callIndex, dinnerIndex) + } + + func testCalendarBlockKeepsInProgressEvent() { + let formatter = ISO8601DateFormatter() + let now = formatter.date(from: "2026-07-03T12:00:00Z")! + let events = [ + event("Ongoing offsite", start: "2026-07-03T09:00:00Z", end: "2026-07-03T17:00:00Z") + ] + let block = AICloneContextService.renderCalendarBlock(events: events, now: now) + XCTAssertTrue(block!.contains("Ongoing offsite")) + } + + func testCalendarBlockEmptyStillReturnsRealAvailability() { + let now = ISO8601DateFormatter().date(from: "2026-07-03T12:00:00Z")! + let block = AICloneContextService.renderCalendarBlock(events: [], now: now) + XCTAssertNotNil(block) + XCTAssertTrue(block!.contains("No commitments")) + } + + func testCalendarBlockCarriesNoBookingRule() { + let now = ISO8601DateFormatter().date(from: "2026-07-03T12:00:00Z")! + let block = AICloneContextService.renderCalendarBlock(events: [], now: now)! + XCTAssertTrue(block.contains("NEVER claim you booked")) + XCTAssertTrue(block.contains("never invent")) + } + + func testCalendarBlockAllDayEvent() { + let now = ISO8601DateFormatter().date(from: "2026-07-03T12:00:00Z")! + let events = [event("Offsite", start: "2026-07-05", allDay: true)] + let block = AICloneContextService.renderCalendarBlock(events: events, now: now)! + XCTAssertTrue(block.contains("(all day): Offsite")) + } + + func testCalendarBlockTruncationNote() { + let formatter = ISO8601DateFormatter() + let now = formatter.date(from: "2026-07-03T12:00:00Z")! + let events = (0..<40).map { index -> CalendarEvent in + let day = String(format: "%02d", 4 + index % 10) // July 4–13, all inside the window + let hour = String(format: "%02d", 10 + index % 8) + return event( + "Event \(index)", start: "2026-07-\(day)T\(hour):00:00Z", + end: "2026-07-\(day)T\(hour):30:00Z") + } + let block = AICloneContextService.renderCalendarBlock(events: events, now: now)! + let lineCount = block.components(separatedBy: "\n").filter { $0.hasPrefix("- ") }.count + XCTAssertEqual(lineCount, AICloneContextService.maxCalendarEvents) + XCTAssertTrue(block.contains("more events not listed")) + } + + // MARK: - Event date parsing + + func testParseEventDateFormats() { + XCTAssertNotNil(AICloneContextService.parseEventDate("2026-07-06T14:00:00-07:00")) + XCTAssertNotNil(AICloneContextService.parseEventDate("2026-07-06T14:00:00Z")) + XCTAssertNotNil(AICloneContextService.parseEventDate("2026-07-06")) + XCTAssertNil(AICloneContextService.parseEventDate("")) + XCTAssertNil(AICloneContextService.parseEventDate("not a date")) + } +} diff --git a/desktop/macos/Desktop/Tests/AICloneSendModeTests.swift b/desktop/macos/Desktop/Tests/AICloneSendModeTests.swift new file mode 100644 index 00000000000..0a449105dce --- /dev/null +++ b/desktop/macos/Desktop/Tests/AICloneSendModeTests.swift @@ -0,0 +1,229 @@ +import XCTest + +@testable import Omi_Computer + +/// Logic tests for the AI Clone send-mode system. These cover the pure, side-effect-free +/// parts — platform routing, the incoming-action decision (the autonomous kill switch), and +/// mode labels — so the safety-critical rule ("Autonomous NEVER sends while paused") is +/// verified without touching the network, Messages.app, or any real contact. +final class AICloneSendModeTests: XCTestCase { + + // MARK: - Platform routing + + func testPlatformRoutingByContactIdPrefix() { + XCTAssertEqual(AIClonePlatform.of(contactId: "telegram:12345"), .telegram) + XCTAssertEqual(AIClonePlatform.of(contactId: "whatsapp:chat.txt"), .whatsapp) + XCTAssertEqual(AIClonePlatform.of(contactId: "whatsapp:14155550123"), .whatsapp) + // iMessage handles are stored unprefixed (phone or email). + XCTAssertEqual(AIClonePlatform.of(contactId: "+14155550123"), .imessage) + XCTAssertEqual(AIClonePlatform.of(contactId: "friend@example.com"), .imessage) + } + + func testAllPlatformsCanSend() { + // WhatsApp gained a real send backend (local Baileys sidecar); the other two were + // already sendable. If a platform ever regresses to import-only, this must be a + // deliberate change. + XCTAssertTrue(AIClonePlatform.imessage.canSend) + XCTAssertTrue(AIClonePlatform.telegram.canSend) + XCTAssertTrue(AIClonePlatform.whatsapp.canSend) + } + + // MARK: - Incoming-action decision (the kill switch) + + func testManualNeverActsOnIncoming() { + XCTAssertEqual(AICloneSendModeService.action(for: .manual, isPaused: true), .ignore) + XCTAssertEqual(AICloneSendModeService.action(for: .manual, isPaused: false), .ignore) + } + + func testDraftReviewAlwaysDraftsNeverSends() { + XCTAssertEqual(AICloneSendModeService.action(for: .draftReview, isPaused: true), .draft) + XCTAssertEqual(AICloneSendModeService.action(for: .draftReview, isPaused: false), .draft) + } + + /// The core safety guarantee: an Autonomous contact must NOT auto-send while the global + /// switch is paused — it degrades to a draft instead. + func testAutonomousPausedDegradesToDraftAndNeverSends() { + XCTAssertEqual(AICloneSendModeService.action(for: .autonomous, isPaused: true), .draft) + XCTAssertNotEqual(AICloneSendModeService.action(for: .autonomous, isPaused: true), .autoSend) + } + + func testAutonomousActiveAutoSends() { + XCTAssertEqual(AICloneSendModeService.action(for: .autonomous, isPaused: false), .autoSend) + } + + /// Exhaustive matrix — no (mode, paused) combination other than (autonomous, active) + /// may ever resolve to `.autoSend`. + func testAutoSendOnlyWhenAutonomousAndActive() { + for mode in SendMode.allCases { + for paused in [true, false] { + let action = AICloneSendModeService.action(for: mode, isPaused: paused) + if action == .autoSend { + XCTAssertEqual(mode, .autonomous) + XCTAssertFalse(paused) + } + } + } + } + + // MARK: - WhatsApp autonomous acknowledgment gate + + /// The WhatsApp-specific extra safety step: Autonomous on a WhatsApp contact needs the + /// one-time unofficial-connection acknowledgment. Exhaustive (mode × platform × ack) + /// matrix, mirroring the kill-switch matrix above. + func testWhatsAppAutonomousGateMatrix() { + let contactsByPlatform: [(id: String, isWhatsApp: Bool)] = [ + ("+14155550123", false), // iMessage + ("telegram:12345", false), + ("whatsapp:14155550123", true), + ("whatsapp:WhatsApp Chat with Mom.txt", true), + ] + for (contactId, isWhatsApp) in contactsByPlatform { + for mode in SendMode.allCases { + for acknowledged in [true, false] { + let required = AICloneSendModeService.requiresWhatsAppAutonomousAcknowledgment( + mode: mode, contactId: contactId, acknowledged: acknowledged) + // The gate fires ONLY for (autonomous, whatsapp, not-yet-acknowledged). + let expected = mode == .autonomous && isWhatsApp && !acknowledged + XCTAssertEqual( + required, expected, + "gate mismatch for \(contactId) mode=\(mode) acknowledged=\(acknowledged)") + } + } + } + } + + func testWhatsAppGateNeverBlocksManualOrDraftReview() { + for contactId in ["whatsapp:14155550123", "whatsapp:chat.txt"] { + for acknowledged in [true, false] { + XCTAssertFalse( + AICloneSendModeService.requiresWhatsAppAutonomousAcknowledgment( + mode: .manual, contactId: contactId, acknowledged: acknowledged)) + XCTAssertFalse( + AICloneSendModeService.requiresWhatsAppAutonomousAcknowledgment( + mode: .draftReview, contactId: contactId, acknowledged: acknowledged)) + } + } + } + + // MARK: - WhatsApp send-target resolution + + func testWhatsAppDirectTargetAcceptsPhonesAndJids() { + XCTAssertEqual(AICloneSendModeService.whatsAppDirectTarget(rawId: "14155550123"), "14155550123") + XCTAssertEqual( + AICloneSendModeService.whatsAppDirectTarget(rawId: "+1 (415) 555-0123"), "14155550123") + XCTAssertEqual( + AICloneSendModeService.whatsAppDirectTarget(rawId: "14155550123@s.whatsapp.net"), + "14155550123@s.whatsapp.net") + } + + func testWhatsAppDirectTargetRejectsImportFilenamesAndJunk() { + // Imported-export contact ids are filenames — never directly addressable. + XCTAssertNil(AICloneSendModeService.whatsAppDirectTarget(rawId: "WhatsApp Chat with Mom.txt")) + XCTAssertNil(AICloneSendModeService.whatsAppDirectTarget(rawId: "_chat.txt")) + XCTAssertNil(AICloneSendModeService.whatsAppDirectTarget(rawId: "")) + XCTAssertNil(AICloneSendModeService.whatsAppDirectTarget(rawId: "123")) // too short + } + + // MARK: - WhatsApp incoming-contact resolution + + private let activeWhatsAppContacts: [(id: String, displayName: String)] = [ + (id: "whatsapp:14155550123", displayName: "Alice"), + (id: "whatsapp:WhatsApp Chat with Mom.txt", displayName: "Mom"), + (id: "whatsapp:WhatsApp Chat with Bob.txt", displayName: "Bob"), + ] + + func testIncomingResolvesDirectPhoneId() { + XCTAssertEqual( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "14155550123", senderName: "Someone Else", + activeWhatsAppContacts: activeWhatsAppContacts, phoneMap: [:]), + "whatsapp:14155550123") + } + + func testIncomingResolvesViaLearnedPhoneMapping() { + XCTAssertEqual( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550001", senderName: nil, + activeWhatsAppContacts: activeWhatsAppContacts, + phoneMap: ["whatsapp:WhatsApp Chat with Mom.txt": "4915550001"]), + "whatsapp:WhatsApp Chat with Mom.txt") + } + + func testIncomingResolvesViaUniqueNameMatchCaseInsensitive() { + XCTAssertEqual( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550002", senderName: " mom ", + activeWhatsAppContacts: activeWhatsAppContacts, phoneMap: [:]), + "whatsapp:WhatsApp Chat with Mom.txt") + } + + func testIncomingRefusesAmbiguousNameMatch() { + let ambiguous = activeWhatsAppContacts + [(id: "whatsapp:mom2.txt", displayName: "Mom")] + XCTAssertNil( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550003", senderName: "Mom", + activeWhatsAppContacts: ambiguous, phoneMap: [:])) + } + + func testIncomingUnknownSenderResolvesToNothing() { + XCTAssertNil( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550004", senderName: "Stranger", + activeWhatsAppContacts: activeWhatsAppContacts, phoneMap: [:])) + XCTAssertNil( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550005", senderName: nil, + activeWhatsAppContacts: activeWhatsAppContacts, phoneMap: [:])) + XCTAssertNil( + AICloneSendModeService.resolveWhatsAppContactId( + phone: "4915550006", senderName: " ", + activeWhatsAppContacts: activeWhatsAppContacts, phoneMap: [:])) + } + + // MARK: - WhatsApp link-state mapping (sidecar JSON → Swift state) + + func testLinkStateMappingFromSidecarJson() { + XCTAssertEqual( + WhatsAppSendService.linkState(fromStatusJson: ["state": "linked", "phone": "14155550123"]), + .linked(phone: "14155550123")) + XCTAssertEqual( + WhatsAppSendService.linkState(fromStatusJson: [ + "state": "waiting_qr", "qrDataUrl": "data:image/png;base64,AAA", + ]), + .waitingScan(qrDataUrl: "data:image/png;base64,AAA")) + // waiting_qr without a QR yet degrades to connecting (poll again shortly). + XCTAssertEqual( + WhatsAppSendService.linkState(fromStatusJson: ["state": "waiting_qr"]), .connecting) + XCTAssertEqual(WhatsAppSendService.linkState(fromStatusJson: ["state": "connecting"]), .connecting) + XCTAssertEqual(WhatsAppSendService.linkState(fromStatusJson: ["state": "unlinked"]), .unlinked) + XCTAssertEqual(WhatsAppSendService.linkState(fromStatusJson: ["state": "logged_out"]), .loggedOut) + } + + // MARK: - Codable + labels + + func testSendModeRoundTrips() throws { + for mode in SendMode.allCases { + let data = try JSONEncoder().encode(mode) + let decoded = try JSONDecoder().decode(SendMode.self, from: data) + XCTAssertEqual(decoded, mode) + } + } + + func testSendModeDefaultRawValues() { + XCTAssertEqual(SendMode.manual.rawValue, "manual") + XCTAssertEqual(SendMode.draftReview.rawValue, "draftReview") + XCTAssertEqual(SendMode.autonomous.rawValue, "autonomous") + } + + func testSentLogEntryRoundTrips() throws { + let entry = AICloneSentLogEntry( + contactId: "telegram:42", contactDisplayName: "Saved Messages", + text: "hello", mode: .manual, timestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let data = try JSONEncoder().encode(entry) + let decoded = try JSONDecoder().decode(AICloneSentLogEntry.self, from: data) + XCTAssertEqual(decoded.contactId, entry.contactId) + XCTAssertEqual(decoded.text, "hello") + XCTAssertEqual(decoded.mode, .manual) + } + +} diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index 416824cbd07..b5ab1502232 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -28,7 +28,7 @@ final class AgentPillLifecycleTests: XCTestCase { } func testProviderCorrectionUsesPreviousFloatingRequestObjective() throws { - let directive = AgentPillsManager.providerDirective( + let directive = AgentPillsManager.literalProviderDirective( from: "I meant ask OpenClaw", contextualPreviousRequest: "ask grok to search for david zhang on X and tell me who the top 3 are") @@ -40,19 +40,34 @@ final class AgentPillLifecycleTests: XCTestCase { let source = try floatingControlBarWindowSource() XCTAssertTrue(source.contains("contextualPreviousRequest: recentVisibleUserRequest(in: barWindow)")) + XCTAssertTrue(source.contains("await AgentPillsManager.providerDirective(")) XCTAssertTrue(source.contains("private func recentVisibleUserRequest(in barWindow: FloatingControlBarWindow) -> String?")) XCTAssertTrue(source.contains("barWindow.state.chatHistory.reversed().compactMap")) } func testTypedProviderDirectivePromptsForSetupWhenProviderUnavailable() throws { let source = try floatingControlBarWindowSource() + let responseSource = try aiResponseViewSource() XCTAssertTrue(source.contains("LocalAgentProviderDetector.availability(for: directive.provider)")) XCTAssertTrue(source.contains("guard availability.isAvailable else")) XCTAssertTrue(source.contains("floating-agent-provider-unavailable")) + XCTAssertTrue(source.contains("presentAgentInstallPrompt(")) XCTAssertTrue(source.contains("completeVisibleAgentResponse(")) + XCTAssertTrue(source.contains("case .beginConnection:")) + XCTAssertTrue(source.contains("case .runSetup:")) + XCTAssertTrue(source.contains("$0.status = .confirming")) + XCTAssertTrue(source.contains("AgentInstallPromptState.setupConfirmationDelay")) + XCTAssertTrue(source.contains("prompt.confirmingSince == nil")) + XCTAssertTrue(source.contains("runAgentInstaller(messageId: messageId, plan: prompt.plan, command: command)")) + XCTAssertTrue(source.contains("$0.confirmingSince = nil")) XCTAssertFalse(source.contains("completeVisibleProviderSetupPrompt(")) XCTAssertTrue(source.contains("FloatingBarVoicePlaybackService.shared.speakOneShot(directive.provider.setupNeededStatus)")) + XCTAssertTrue(responseSource.contains("private var primaryActionControl: some View")) + XCTAssertTrue(responseSource.contains(".onTapGesture {")) + XCTAssertFalse(responseSource.contains("Button(action: onInstall)")) + XCTAssertFalse(responseSource.contains(".accessibilityAction {")) + XCTAssertFalse(responseSource.contains(".accessibilityAddTraits(.isButton)")) } func testSubagentChatSpawnRequestCreatesSiblingAgent() throws { diff --git a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift index a8c95dc93c1..f463b704095 100644 --- a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift +++ b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift @@ -56,6 +56,7 @@ final class AgentRuntimeProcessTests: XCTestCase { XCTAssertEqual(AgentRuntimeProcess.adapterId(forHarnessMode: "hermes"), "hermes") XCTAssertEqual(AgentRuntimeProcess.adapterId(forHarnessMode: "openclaw"), "openclaw") XCTAssertEqual(AgentRuntimeProcess.adapterId(forHarnessMode: "openClaw"), "openclaw") + XCTAssertEqual(AgentRuntimeProcess.adapterId(forHarnessMode: "codex"), "codex") XCTAssertNil(AgentRuntimeProcess.adapterId(forHarnessMode: "unknown")) } @@ -162,6 +163,7 @@ final class AgentRuntimeProcessTests: XCTestCase { XCTAssertTrue(source.contains(#"env["PATH"] = pathElements.joined(separator: ":")"#)) XCTAssertTrue(source.contains(#"env["OMI_OPENCLAW_ADAPTER_COMMAND"]"#)) XCTAssertTrue(source.contains(#"env["OMI_HERMES_ADAPTER_COMMAND"]"#)) + XCTAssertTrue(source.contains(#"env["OMI_CODEX_ADAPTER_COMMAND"]"#)) } func testOpenClawAdapterCommandUsesSiblingNodeWhenAvailable() throws { diff --git a/desktop/macos/Desktop/Tests/HermesConnectFlowTests.swift b/desktop/macos/Desktop/Tests/HermesConnectFlowTests.swift new file mode 100644 index 00000000000..b43ced8a67a --- /dev/null +++ b/desktop/macos/Desktop/Tests/HermesConnectFlowTests.swift @@ -0,0 +1,335 @@ +import XCTest + +@testable import Omi_Computer + +/// Covers the Hermes → Nous device-code connect flow: auth-signal probing, +/// installed-but-signed-out routing, CLI stdout parsing, and prompt copy. +final class HermesConnectFlowTests: XCTestCase { + + // MARK: - Helpers + + private func makeTempHome(withHermesExecutable: Bool = true) throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("omi-hermes-connect-\(UUID().uuidString)", isDirectory: true) + if withHermesExecutable { + let bin = home.appendingPathComponent(".local/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let executable = bin.appendingPathComponent("hermes") + try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: executable.path) + } else { + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + } + return home + } + + private func writeHermesFile(_ home: URL, name: String, contents: String) throws { + let hermesDir = home.appendingPathComponent(".hermes", isDirectory: true) + try FileManager.default.createDirectory(at: hermesDir, withIntermediateDirectories: true) + try contents.write( + to: hermesDir.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + + // MARK: - HermesAuthProbe + + func testProbeFindsNoCredentialsInEmptyHome() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + + XCTAssertFalse( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + XCTAssertFalse( + HermesAuthProbe.isNousAuthenticated(environment: [:], homeDirectory: home.path)) + } + + func testProbeDetectsNousRefreshToken() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeHermesFile( + home, name: "auth.json", + contents: #"{"providers": {"nous": {"access_token": "jwt", "refresh_token": "rt-1"}}}"#) + + XCTAssertTrue( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + XCTAssertTrue( + HermesAuthProbe.isNousAuthenticated(environment: [:], homeDirectory: home.path)) + } + + func testProbeTreatsQuarantinedNousStateAsSignedOut() throws { + // Hermes strips token fields on quarantine but keeps routing metadata. + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeHermesFile( + home, name: "auth.json", + contents: #"{"providers": {"nous": {"portal_base_url": "https://portal.nousresearch.com", "last_auth_error": {"code": "invalid_grant"}}}}"#) + + XCTAssertFalse( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + XCTAssertFalse( + HermesAuthProbe.isNousAuthenticated(environment: [:], homeDirectory: home.path)) + } + + func testProbeDetectsOtherProviderCredentials() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeHermesFile( + home, name: "auth.json", + contents: #"{"credential_pool": {"openrouter": [{"auth_type": "api_key"}]}}"#) + + XCTAssertTrue( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + XCTAssertFalse( + HermesAuthProbe.isNousAuthenticated(environment: [:], homeDirectory: home.path)) + } + + func testProbeDetectsEnvFileAPIKeyButIgnoresCommentsAndBlanks() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeHermesFile( + home, name: ".env", + contents: """ + # OPENROUTER_API_KEY=commented-out + EMPTY_API_KEY= + SOME_SETTING=on + """) + XCTAssertFalse( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + + try writeHermesFile( + home, name: ".env", + contents: "OPENROUTER_API_KEY=sk-or-live\n") + XCTAssertTrue( + HermesAuthProbe.hasAnyInferenceCredential(environment: [:], homeDirectory: home.path)) + } + + func testProbeDetectsProcessEnvironmentKey() { + XCTAssertTrue( + HermesAuthProbe.hasAnyInferenceCredential( + environment: ["ANTHROPIC_API_KEY": "sk-ant-1"], + homeDirectory: "/tmp/missing-home-\(UUID().uuidString)")) + } + + func testProbeHonorsHermesHomeOverride() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + let customHermesHome = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: customHermesHome, withIntermediateDirectories: true) + try #"{"providers": {"nous": {"refresh_token": "rt-2"}}}"#.write( + to: customHermesHome.appendingPathComponent("auth.json"), atomically: true, encoding: .utf8) + + XCTAssertTrue( + HermesAuthProbe.isNousAuthenticated( + environment: ["HERMES_HOME": customHermesHome.path], + homeDirectory: "/tmp/missing-home")) + } + + // MARK: - Detector routing + + func testDetectorRoutesInstalledButSignedOutHermesToAuthentication() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + + let availability = LocalAgentProviderDetector.availability( + for: .hermes, + environment: [:], + homeDirectory: home.path) + + XCTAssertFalse(availability.isAvailable) + XCTAssertTrue(availability.needsAuthentication) + XCTAssertEqual( + availability.setupPrompt, + "Hermes is installed but isn't signed in yet. I can open the Nous sign-in page in your browser to connect it.") + } + + func testDetectorKeepsAuthenticatedHermesAvailable() throws { + let home = try makeTempHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeHermesFile( + home, name: "auth.json", + contents: #"{"providers": {"nous": {"refresh_token": "rt-3"}}}"#) + + let availability = LocalAgentProviderDetector.availability( + for: .hermes, + environment: [:], + homeDirectory: home.path) + + XCTAssertTrue(availability.isAvailable) + } + + func testDetectorMissingHermesStillReportsMissing() { + let availability = LocalAgentProviderDetector.availability( + for: .hermes, + environment: ["PATH": "/tmp/definitely-missing-\(UUID().uuidString)"], + homeDirectory: "/tmp/missing-home") + + XCTAssertEqual(availability.status, .missing) + XCTAssertFalse(availability.needsAuthentication) + } + + func testDetectorAuthCheckSkipsProvidersWithoutSetupProbe() throws { + // Codex has no auth/onboard probe: a found executable is simply available. + let home = try makeTempHome(withHermesExecutable: false) + defer { try? FileManager.default.removeItem(at: home) } + let bin = home.appendingPathComponent(".local/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let executable = bin.appendingPathComponent("codex") + try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let availability = LocalAgentProviderDetector.availability( + for: .codex, + environment: [:], + homeDirectory: home.path) + + XCTAssertTrue(availability.isAvailable) + } + + func testDetectorTreatsUnonboardedOpenClawAsNeedsAuth() throws { + // OpenClaw installs the binary with `--no-onboard`; a present binary with + // no config is "installed but not set up", not "available". + let home = try makeTempHome(withHermesExecutable: false) + defer { try? FileManager.default.removeItem(at: home) } + let bin = home.appendingPathComponent(".local/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let executable = bin.appendingPathComponent("openclaw") + try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let notOnboarded = LocalAgentProviderDetector.availability( + for: .openclaw, environment: [:], homeDirectory: home.path) + XCTAssertTrue(notOnboarded.needsAuthentication) + XCTAssertFalse(notOnboarded.isAvailable) + + // Once onboarded (config with Gateway + default model), it is available. + let openClawDir = home.appendingPathComponent(".openclaw", isDirectory: true) + try FileManager.default.createDirectory(at: openClawDir, withIntermediateDirectories: true) + try """ + {"gateway":{"port":18789},"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-4-8"}}}} + """.write(to: openClawDir.appendingPathComponent("openclaw.json"), atomically: true, encoding: .utf8) + + let onboarded = LocalAgentProviderDetector.availability( + for: .openclaw, environment: [:], homeDirectory: home.path) + XCTAssertTrue(onboarded.isAvailable) + } + + // MARK: - Device-code stdout parsing + + func testParserExtractsVerificationURLAndUserCode() { + var parser = HermesDeviceCodeOutputParser() + for line in [ + "Starting Hermes login via Nous Portal...", + "Portal: https://portal.nousresearch.com", + "", + "To continue:", + " 1. Open: https://portal.nousresearch.com/activate?user_code=ABCD-EFGH", + " 2. If prompted, enter code: ABCD-EFGH", + "Waiting for approval (polling every 5s)...", + ] { + parser.consume(line: line) + } + + XCTAssertEqual( + parser.verificationURL?.absoluteString, + "https://portal.nousresearch.com/activate?user_code=ABCD-EFGH") + XCTAssertEqual(parser.userCode, "ABCD-EFGH") + } + + func testParserHandlesMissingUserCodeLine() { + var parser = HermesDeviceCodeOutputParser() + parser.consume(line: " 1. Open: https://portal.nousresearch.com/activate") + + XCTAssertEqual(parser.verificationURL?.absoluteString, "https://portal.nousresearch.com/activate") + XCTAssertNil(parser.userCode) + } + + func testParserIgnoresNonURLOpenLines() { + var parser = HermesDeviceCodeOutputParser() + parser.consume(line: " 1. Open: not a url") + XCTAssertNil(parser.verificationURL) + } + + func testParserKeepsFirstURLAndCode() { + var parser = HermesDeviceCodeOutputParser() + parser.consume(line: "1. Open: https://portal.nousresearch.com/activate?user_code=AAAA") + parser.consume(line: "1. Open: https://evil.example.com/second") + parser.consume(line: "2. If prompted, enter code: AAAA") + parser.consume(line: "2. If prompted, enter code: BBBB") + + XCTAssertEqual( + parser.verificationURL?.absoluteString, + "https://portal.nousresearch.com/activate?user_code=AAAA") + XCTAssertEqual(parser.userCode, "AAAA") + } + + // MARK: - Authentication plan + prompt copy + + func testHermesAuthenticationPlanIsAuthenticateKind() { + let plan = AgentPillsManager.DirectedProvider.hermes.authenticationPlan + XCTAssertEqual(plan.kind, .authenticate) + XCTAssertNil(plan.installCommand) + XCTAssertEqual( + plan.documentationURL.absoluteString, + "https://hermes-agent.nousresearch.com/docs/integrations/nous-portal") + } + + func testInstallPlansStayInstallKind() { + XCTAssertEqual(AgentPillsManager.DirectedProvider.hermes.installPlan.kind, .install) + // OpenClaw's installer is still an install plan, but it now has a distinct + // authenticate plan for the post-install onboarding step. + XCTAssertEqual(AgentPillsManager.DirectedProvider.openclaw.installPlan.kind, .install) + XCTAssertEqual(AgentPillsManager.DirectedProvider.openclaw.authenticationPlan.kind, .authenticate) + XCTAssertNil(AgentPillsManager.DirectedProvider.openclaw.authenticationPlan.installCommand) + } + + func testOpenClawAuthenticatePromptCopyIsNotBrowserFlow() { + let plan = AgentPillsManager.DirectedProvider.openclaw.authenticationPlan + let state = AgentInstallPromptState(plan: plan) + // Must not claim a browser sign-in — OpenClaw onboards non-interactively. + XCTAssertFalse(state.detailText.lowercased().contains("browser")) + XCTAssertTrue(state.detailText.contains("OpenClaw")) + XCTAssertEqual(state.primaryActionTitle, "Connect OpenClaw") + } + + func testPromptCopyForAuthenticateFlow() { + let plan = AgentPillsManager.DirectedProvider.hermes.authenticationPlan + var state = AgentInstallPromptState(plan: plan) + + XCTAssertEqual( + state.detailText, + "Omi will open the sign-in page in your browser, then wait for your approval.") + XCTAssertEqual(state.primaryActionTitle, "Connect Hermes") + + state.status = .waitingForApproval(userCode: "ABCD-EFGH") + XCTAssertTrue(state.status.isBusy) + XCTAssertEqual( + state.detailText, + "Waiting for approval in your browser… If the page asks for a code, enter the one below.") + + state.status = .waitingForApproval(userCode: nil) + XCTAssertEqual(state.detailText, "Waiting for approval in your browser…") + + state.status = .authFailed(message: "Timed out waiting for device authorization") + XCTAssertFalse(state.status.isBusy) + XCTAssertEqual(state.primaryActionTitle, "Retry sign-in") + XCTAssertTrue(state.primaryActionEnabled) + XCTAssertEqual(state.primaryAction, .beginConnection) + + state.status = .connected + XCTAssertEqual(state.detailText, "Hermes is connected. Try your request again.") + } + + func testFailureDetailTrimsTranscript() { + let transcript = """ + Starting Hermes login via Nous Portal... + Waiting for approval (polling every 5s)... + expired_token: Device code has expired + """ + XCTAssertEqual( + HermesConnectService.failureDetail(fromTranscript: transcript), + "Starting Hermes login via Nous Portal... expired_token: Device code has expired") + XCTAssertEqual(HermesConnectService.failureDetail(fromTranscript: ""), "") + } +} diff --git a/desktop/macos/Desktop/Tests/HermesModelProvisionerTests.swift b/desktop/macos/Desktop/Tests/HermesModelProvisionerTests.swift new file mode 100644 index 00000000000..22d9eba5ae4 --- /dev/null +++ b/desktop/macos/Desktop/Tests/HermesModelProvisionerTests.swift @@ -0,0 +1,97 @@ +import XCTest + +@testable import Omi_Computer + +/// Covers the Hermes free-model provisioning that keeps `model.default` pinned +/// to a free Nous model (no paid credits required). +final class HermesModelProvisionerTests: XCTestCase { + + private let free = HermesModelProvisioner.freeDefaultModel + + // MARK: - rewrite (pure transform) + + func testReplacesPaidDefaultWithFreeAndPreservesEverythingElse() throws { + let input = """ + model: + default: qwen/qwen3-235b-a22b-2507 + provider: nous + base_url: https://openrouter.ai/api/v1 + terminal: + backend: local + """ + let out = try XCTUnwrap(HermesModelProvisioner.rewrite(input)) + XCTAssertTrue(out.contains(" default: \(free)")) + XCTAssertFalse(out.contains("qwen/qwen3-235b-a22b-2507")) + // Untouched keys survive verbatim. + XCTAssertTrue(out.contains(" provider: nous")) + XCTAssertTrue(out.contains(" base_url: https://openrouter.ai/api/v1")) + XCTAssertTrue(out.contains("terminal:")) + XCTAssertTrue(out.contains(" backend: local")) + } + + func testNoChangeWhenAlreadyFree() { + let input = """ + model: + default: \(free) + provider: nous + """ + XCTAssertNil(HermesModelProvisioner.rewrite(input)) + } + + func testInsertsDefaultWhenModelBlockHasNone() throws { + let input = """ + model: + provider: nous + terminal: + backend: local + """ + let out = try XCTUnwrap(HermesModelProvisioner.rewrite(input)) + let lines = out.components(separatedBy: "\n") + let modelIdx = try XCTUnwrap(lines.firstIndex(of: "model:")) + XCTAssertEqual(lines[modelIdx + 1], " default: \(free)") + XCTAssertTrue(out.contains(" provider: nous")) + } + + func testPrependsModelBlockWhenAbsent() throws { + let input = """ + terminal: + backend: local + """ + let out = try XCTUnwrap(HermesModelProvisioner.rewrite(input)) + XCTAssertTrue(out.hasPrefix("model:\n default: \(free)\n provider: nous")) + XCTAssertTrue(out.contains("terminal:")) + } + + func testCreatesBlockForEmptyContents() throws { + let out = try XCTUnwrap(HermesModelProvisioner.rewrite("")) + XCTAssertTrue(out.contains("model:")) + XCTAssertTrue(out.contains(" default: \(free)")) + } + + func testIsFreeModel() { + XCTAssertTrue(HermesModelProvisioner.isFreeModel("stepfun/step-3.7-flash:free")) + XCTAssertTrue(HermesModelProvisioner.isFreeModel(" something:free ")) + XCTAssertFalse(HermesModelProvisioner.isFreeModel("qwen/qwen3-235b-a22b-2507")) + } + + // MARK: - ensureFreeDefaultModel (file I/O, idempotent) + + func testEnsureFreeDefaultModelWritesAndIsIdempotent() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("omi-hermes-provision-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let path = dir.appendingPathComponent("config.yaml").path + try "model:\n default: qwen/qwen3-235b-a22b-2507\n provider: nous\n" + .write(toFile: path, atomically: true, encoding: .utf8) + + let changed = HermesModelProvisioner.ensureFreeDefaultModel(configPath: path) + XCTAssertTrue(changed) + let written = try String(contentsOfFile: path, encoding: .utf8) + XCTAssertTrue(written.contains(" default: \(free)")) + XCTAssertFalse(written.contains("qwen/qwen3-235b-a22b-2507")) + + // Second call is a no-op. + XCTAssertFalse(HermesModelProvisioner.ensureFreeDefaultModel(configPath: path)) + } +} diff --git a/desktop/macos/Desktop/Tests/OpenClawOnboardProbeTests.swift b/desktop/macos/Desktop/Tests/OpenClawOnboardProbeTests.swift new file mode 100644 index 00000000000..b0903d02135 --- /dev/null +++ b/desktop/macos/Desktop/Tests/OpenClawOnboardProbeTests.swift @@ -0,0 +1,108 @@ +import XCTest + +@testable import Omi_Computer + +/// Covers detection of OpenClaw's onboarded state (installed-but-not-onboarded +/// vs. genuinely set up) and the non-interactive onboard command. +final class OpenClawOnboardProbeTests: XCTestCase { + + private func makeHome() throws -> String { + let home = NSTemporaryDirectory() + "omi-openclaw-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: home + "/.openclaw", withIntermediateDirectories: true) + return home + } + + private func writeConfig(_ home: String, _ json: String) throws { + try json.write(toFile: home + "/.openclaw/openclaw.json", atomically: true, encoding: .utf8) + } + + func testOnboardedWhenGatewayAndModelPresent() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(atPath: home) } + try writeConfig(home, """ + {"gateway":{"port":18789,"auth":{"mode":"token"}}, + "agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-4-8"}}}} + """) + XCTAssertTrue(OpenClawOnboardProbe.isOnboarded(environment: [:], homeDirectory: home)) + } + + func testNotOnboardedWhenConfigMissing() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(atPath: home) } + // Bare binary install (`--no-onboard`) writes no config. + XCTAssertFalse(OpenClawOnboardProbe.isOnboarded(environment: [:], homeDirectory: home)) + } + + func testNotOnboardedWhenGatewayMissing() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(atPath: home) } + try writeConfig(home, """ + {"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-4-8"}}}} + """) + XCTAssertFalse(OpenClawOnboardProbe.isOnboarded(environment: [:], homeDirectory: home)) + } + + func testNotOnboardedWhenModelMissing() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(atPath: home) } + try writeConfig(home, """ + {"gateway":{"port":18789}} + """) + XCTAssertFalse(OpenClawOnboardProbe.isOnboarded(environment: [:], homeDirectory: home)) + } + + func testRespectsConfigPathOverride() throws { + let dir = NSTemporaryDirectory() + "omi-openclaw-ovr-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let cfg = dir + "/custom.json" + try """ + {"gateway":{"port":19001},"agents":{"defaults":{"model":{"primary":"x/y"}}}} + """.write(toFile: cfg, atomically: true, encoding: .utf8) + XCTAssertTrue(OpenClawOnboardProbe.isOnboarded( + environment: ["OPENCLAW_CONFIG_PATH": cfg], homeDirectory: "/nonexistent")) + } + + func testOnboardArgumentsAreNonInteractiveAndScriptable() { + let args = OpenClawConnectService.onboardArguments + XCTAssertEqual(args.first, "onboard") + XCTAssertTrue(args.contains("--non-interactive")) + XCTAssertTrue(args.contains("--accept-risk")) + XCTAssertTrue(args.contains("--install-daemon")) + // Auth reuses the local Claude sign-in (the app's native default agent). + let idx = args.firstIndex(of: "--auth-choice") + XCTAssertNotNil(idx) + if let idx { XCTAssertEqual(args[idx + 1], "anthropic-cli") } + } + + /// The no-Claude-Code fallback: a user-runnable Terminal command using an + /// OpenRouter API key (real `openclaw onboard` flags), with a placeholder + /// the user replaces — the app never handles the key. + func testManualModelSetupCommandUsesOpenRouterKeyChoice() { + let command = OpenClawConnectService.manualModelSetupCommand + XCTAssertTrue(command.hasPrefix("openclaw onboard ")) + XCTAssertTrue(command.contains("--non-interactive")) + XCTAssertTrue(command.contains("--accept-risk")) + XCTAssertTrue(command.contains("--auth-choice openrouter-api-key")) + XCTAssertTrue(command.contains( + "--openrouter-api-key \(OpenClawConnectService.manualSetupKeyPlaceholder)")) + XCTAssertTrue(command.contains("--install-daemon")) + // Pre-typed via zsh `print -z` inside single quotes and an AppleScript + // double-quoted string — the command must not need escaping in either. + XCTAssertFalse(command.contains("'")) + XCTAssertFalse(command.contains("\"")) + XCTAssertFalse(command.contains("\\")) + } + + func testManualModelSetupPromptStatusDrivesTerminalAction() { + let plan = AgentPillsManager.DirectedProvider.openclaw.authenticationPlan + var state = AgentInstallPromptState(plan: plan) + state.status = .needsManualModelSetup( + command: OpenClawConnectService.manualModelSetupCommand) + XCTAssertEqual(state.primaryActionTitle, "Open Terminal") + XCTAssertEqual(state.primaryAction, .openTerminalSetup) + XCTAssertTrue(state.primaryActionEnabled) + XCTAssertFalse(state.status.isBusy) + XCTAssertEqual(state.status.automationValue, "needsManualModelSetup") + } +} diff --git a/desktop/macos/Desktop/Tests/PiMonoWiringTests.swift b/desktop/macos/Desktop/Tests/PiMonoWiringTests.swift index 5287f79bc58..198dc2898ac 100644 --- a/desktop/macos/Desktop/Tests/PiMonoWiringTests.swift +++ b/desktop/macos/Desktop/Tests/PiMonoWiringTests.swift @@ -41,6 +41,7 @@ final class PiMonoWiringTests: XCTestCase { XCTAssertEqual(AgentRuntimeRouting.adapterId(for: .acp).rawValue, "acp") XCTAssertEqual(AgentRuntimeRouting.adapterId(for: .hermes).rawValue, "hermes") XCTAssertEqual(AgentRuntimeRouting.adapterId(for: .openclaw).rawValue, "openclaw") + XCTAssertEqual(AgentRuntimeRouting.adapterId(for: .codex).rawValue, "codex") XCTAssertNil(AgentRuntimeRouting.harnessMode(from: "unknown")) } @@ -64,6 +65,9 @@ final class PiMonoWiringTests: XCTestCase { let executable = bin.appendingPathComponent("openclaw") try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + // A bare binary without an onboarded config reports needsAuthentication, + // so give this fake install an onboarded config to assert discovery. + try writeOnboardedOpenClawConfig(home: home) let availability = LocalAgentProviderDetector.availability( for: .openclaw, @@ -73,25 +77,63 @@ final class PiMonoWiringTests: XCTestCase { XCTAssertEqual(availability.status, .available(command: executable.path)) } - func testLocalAgentProviderDetectorIgnoresArbitraryPathEntries() throws { + func testLocalAgentProviderDetectorFindsExecutableInOpenClawInstallPath() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("omi-provider-openclaw-\(UUID().uuidString)", isDirectory: true) + let bin = home.appendingPathComponent(".openclaw/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let executable = bin.appendingPathComponent("openclaw") + try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + try writeOnboardedOpenClawConfig(home: home) + + let availability = LocalAgentProviderDetector.availability( + for: .openclaw, + environment: [:], + homeDirectory: home.path) + + XCTAssertEqual(availability.status, .available(command: executable.path)) + } + + /// Minimal config that `OpenClawOnboardProbe.isOnboarded` accepts (Gateway + /// port + default model), so detector tests can model an onboarded install. + private func writeOnboardedOpenClawConfig(home: URL) throws { + let dir = home.appendingPathComponent(".openclaw", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try """ + {"gateway":{"port":18789},"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-4-8"}}}} + """.write(to: dir.appendingPathComponent("openclaw.json"), atomically: true, encoding: .utf8) + } + + func testLocalAgentProviderDetectorFindsExecutableInPathEnvironment() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("omi-provider-path-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let executable = root.appendingPathComponent("hermes") + let executable = root.appendingPathComponent("codex") try "#!/bin/sh\nexit 0\n".write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) let availability = LocalAgentProviderDetector.availability( - for: .hermes, + for: .codex, environment: ["PATH": root.path], homeDirectory: "/tmp/missing-home") - XCTAssertFalse(availability.isAvailable) + XCTAssertEqual(availability.status, .available(command: executable.path)) } - func testLocalAgentProviderDetectorMissingPromptIsUserFacing() { + func testLocalAgentProviderDetectorMissingPromptIsUserFacing() throws { + // The detector also searches fixed system dirs (/opt/homebrew/bin, ...) + // that tests can't redirect; a real global install makes "missing" + // untestable on this machine. + for dir in ["/opt/homebrew/bin", "/usr/local/bin"] { + if FileManager.default.isExecutableFile(atPath: "\(dir)/openclaw") { + throw XCTSkip("openclaw is installed at \(dir); missing-state is not reproducible here") + } + } let availability = LocalAgentProviderDetector.availability( for: .openclaw, environment: ["PATH": "/tmp/definitely-missing-\(UUID().uuidString)"], @@ -100,10 +142,46 @@ final class PiMonoWiringTests: XCTestCase { XCTAssertFalse(availability.isAvailable) XCTAssertEqual( availability.setupPrompt, - "I don't see OpenClaw installed. Make sure OpenClaw is installed first, then try again.") + "I don't see OpenClaw connected. I can run the official OpenClaw installer or open setup docs.") XCTAssertEqual( availability.toolError, - "Error: I don't see OpenClaw installed. Make sure OpenClaw is installed first, then try again.") + "Error: I don't see OpenClaw connected. I can run the official OpenClaw installer or open setup docs.") + } + + func testLocalAgentProviderDetectorCodexMissingPromptIsUserFacing() throws { + for dir in ["/opt/homebrew/bin", "/usr/local/bin"] { + if FileManager.default.isExecutableFile(atPath: "\(dir)/codex") { + throw XCTSkip("codex is installed at \(dir); missing-state is not reproducible here") + } + } + let availability = LocalAgentProviderDetector.availability( + for: .codex, + environment: ["PATH": "/tmp/definitely-missing-\(UUID().uuidString)"], + homeDirectory: "/tmp/missing-home") + + XCTAssertFalse(availability.isAvailable) + XCTAssertEqual( + availability.setupPrompt, + "I don't see Codex connected. I can run the official Codex CLI installer or open setup docs.") + XCTAssertEqual( + availability.toolError, + "Error: I don't see Codex connected. I can run the official Codex CLI installer or open setup docs.") + } + + func testDirectedProviderInstallPlansUseOfficialSetupSources() { + XCTAssertEqual( + AgentPillsManager.DirectedProvider.hermes.installPlan.installCommand, + "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash") + XCTAssertEqual( + AgentPillsManager.DirectedProvider.hermes.installPlan.documentationURL.absoluteString, + "https://hermes-agent.nousresearch.com/docs/getting-started/installation") + XCTAssertEqual( + AgentPillsManager.DirectedProvider.openclaw.installPlan.installCommand, + "curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard") + XCTAssertEqual( + AgentPillsManager.DirectedProvider.codex.installPlan.installCommand, + "curl -fsSL https://chatgpt.com/codex/install.sh | sh") + XCTAssertNil(AgentPillsManager.DirectedProvider.claudeCode.installPlan.installCommand) } // MARK: - ApiKeysResponse shape assertion @@ -249,7 +327,7 @@ final class PiMonoWiringTests: XCTestCase { } func testProviderDirectiveRoutesAskOpenClawToOpenClawHarness() { - let directive = AgentPillsManager.providerDirective(from: "Please ask openclaw how it's going") + let directive = AgentPillsManager.literalProviderDirective(from: "Please ask openclaw how it's going") XCTAssertEqual(directive?.provider, .openclaw) XCTAssertEqual(directive?.provider.harnessMode, .openclaw) @@ -258,7 +336,7 @@ final class PiMonoWiringTests: XCTestCase { } func testProviderDirectiveRoutesHermesToHermesHarness() { - let directive = AgentPillsManager.providerDirective(from: "Hermes: summarize your current status") + let directive = AgentPillsManager.literalProviderDirective(from: "Hermes: summarize your current status") XCTAssertEqual(directive?.provider, .hermes) XCTAssertEqual(directive?.provider.harnessMode, .hermes) @@ -266,12 +344,54 @@ final class PiMonoWiringTests: XCTestCase { XCTAssertEqual(directive?.title, "Hermes") } + func testProviderDirectiveRoutesCodexToCodexHarness() { + let directive = AgentPillsManager.literalProviderDirective(from: "Use Codex inspect this repo") + + XCTAssertEqual(directive?.provider, .codex) + XCTAssertEqual(directive?.provider.harnessMode, .codex) + XCTAssertEqual(directive?.rewrittenQuery, "inspect this repo") + XCTAssertEqual(directive?.title, "Codex") + } + func testProviderDirectiveIgnoresNonProviderQuestions() { - XCTAssertNil(AgentPillsManager.providerDirective(from: "what is openclaw?")) - XCTAssertNil(AgentPillsManager.providerDirective(from: "openclaw architecture")) - XCTAssertNil(AgentPillsManager.providerDirective(from: "hermes scarf")) - XCTAssertNil(AgentPillsManager.providerDirective(from: "compare hermes and openclaw")) - XCTAssertNil(AgentPillsManager.providerDirective(from: "how is it going?")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "what is openclaw?")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "openclaw architecture")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "codex architecture")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "hermes scarf")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "compare hermes and openclaw")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "what is codex?")) + XCTAssertNil(AgentPillsManager.literalProviderDirective(from: "how is it going?")) + } + + func testProviderDirectiveClassifierParsesNaturalOpenClawDelegation() { + let directive = AgentPillsManager.parseProviderDirectiveClassifierOutput( + #"{"provider":"openclaw","task":"message Nook on Telegram"}"#, + originalText: "can you use openclaw to message nook on telegram", + contextualPreviousRequest: nil) + + XCTAssertEqual(directive?.provider, .openclaw) + XCTAssertEqual(directive?.rewrittenQuery, "message Nook on Telegram") + XCTAssertEqual(directive?.title, "OpenClaw") + } + + func testAgentInstallPromptFirstClickCanStopBeforeRunningSetup() { + var prompt = AgentInstallPromptState(plan: AgentPillsManager.DirectedProvider.openclaw.installPlan) + + XCTAssertEqual(prompt.primaryActionTitle, "Connect OpenClaw") + XCTAssertEqual(prompt.status.automationValue, "ready") + XCTAssertEqual(prompt.primaryAction, .beginConnection) + XCTAssertTrue(prompt.primaryActionEnabled) + + prompt.status = .confirming + prompt.confirmingSince = Date() + + XCTAssertEqual(prompt.primaryActionTitle, "Run setup") + XCTAssertEqual(prompt.primaryAction, .runSetup) + XCTAssertEqual(prompt.status.automationValue, "confirming") + XCTAssertFalse(prompt.primaryActionEnabled) + prompt.confirmingSince = nil + XCTAssertTrue(prompt.primaryActionEnabled) + XCTAssertEqual(prompt.detailText, "Ready to run the setup command shown below.") } // MARK: - Rename completeness: no ACPBridge / acp-bridge in Swift sources diff --git a/desktop/macos/Desktop/Tests/RealtimeHubSpawnAgentTests.swift b/desktop/macos/Desktop/Tests/RealtimeHubSpawnAgentTests.swift index c48d8351ad3..70eb3aae99a 100644 --- a/desktop/macos/Desktop/Tests/RealtimeHubSpawnAgentTests.swift +++ b/desktop/macos/Desktop/Tests/RealtimeHubSpawnAgentTests.swift @@ -28,13 +28,19 @@ final class RealtimeHubSpawnAgentTests: XCTestCase { XCTAssertTrue(source.contains("LocalAgentProviderDetector.availability(for: directedProvider)")) XCTAssertTrue(source.contains("guard availability.isAvailable else")) XCTAssertTrue(source.contains("assistantText = setupPrompt")) + XCTAssertTrue(source.contains("FloatingControlBarManager.shared.presentAgentInstallPrompt(")) XCTAssertTrue(source.contains("output: availability.toolError")) - XCTAssertTrue(source.contains(""" - sendToolResultIfCurrent( - source: source, callId: callId, name: name, - output: availability.toolError) - return -""")) + XCTAssertTrue(source.contains("sendToolResultIfCurrent(")) + XCTAssertTrue(source.contains("return")) + } + + func testCommitTurnPreflightsExplicitProviderDirectiveBeforeRealtimeModelAnswers() throws { + let source = try realtimeHubControllerSource() + + XCTAssertTrue(source.contains("commitTurnAfterProviderDirectivePreflight(")) + XCTAssertTrue(source.contains("await AgentPillsManager.providerDirective(")) + XCTAssertTrue(source.contains("activeSession.abandonInputTurn()")) + XCTAssertTrue(source.contains("realtime-agent-provider-unavailable")) } func testCanonicalAgentControlSummariesDoNotSpeakOpaqueIds() throws { diff --git a/desktop/macos/Desktop/Tests/TaskChatLegacyAcpMigrationTests.swift b/desktop/macos/Desktop/Tests/TaskChatLegacyAcpMigrationTests.swift index 4f3174577d1..51702b9f3be 100644 --- a/desktop/macos/Desktop/Tests/TaskChatLegacyAcpMigrationTests.swift +++ b/desktop/macos/Desktop/Tests/TaskChatLegacyAcpMigrationTests.swift @@ -37,7 +37,10 @@ final class TaskChatLegacyAcpMigrationTests: XCTestCase { // (ACP/pi-mono), preventing cross-adapter resume ID pollution. XCTAssertTrue(source.contains("private var currentHarness: String?")) XCTAssertTrue(source.contains("currentHarness = harness")) - XCTAssertTrue(source.contains("let supportsLegacyResume = (currentHarness == \"acp\" || currentHarness == \"piMono\")")) + XCTAssertTrue(source.contains("let supportsLegacyResume = (harness == \"acp\" || harness == \"piMono\")")) + XCTAssertTrue(source.contains("let actualAdapterId =")) + XCTAssertTrue(source.contains("queryResult.adapterId")) + XCTAssertTrue(source.contains("let supportsLegacyResume = (actualAdapterId == \"acp\" || actualAdapterId == \"pi-mono\")")) } func testTaskChatFailureKeepsVisibleAssistantMessage() throws { diff --git a/desktop/macos/agent/src/adapters/codex.ts b/desktop/macos/agent/src/adapters/codex.ts new file mode 100644 index 00000000000..94876b4138d --- /dev/null +++ b/desktop/macos/agent/src/adapters/codex.ts @@ -0,0 +1,386 @@ +import { spawn, type ChildProcess } from "child_process"; +import { adapterCapabilitiesFor } from "./interface.js"; +import type { + AdapterAttemptContext, + AdapterAttemptResult, + AdapterBindingHandle, + AdapterCapabilities, + AdapterEventSink, + CancelAttemptContext, + CancelDispatchResult, + OpenBindingInput, + OpenedBinding, + ResumeBindingInput, + RuntimeAdapter, +} from "./interface.js"; + +export interface CodexRuntimeAdapterOptions { + command?: string; + envCommandName?: string; + log?: (message: string) => void; +} + +const CODEX_ADAPTER_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "TERM", + "CODEX_HOME", + "CODEX_API_KEY", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", +] as const; + +const PROXY_ENV_KEYS = new Set([ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +]); + +export class CodexRuntimeAdapter implements RuntimeAdapter { + readonly adapterId = "codex"; + readonly capabilities: AdapterCapabilities = adapterCapabilitiesFor("codex"); + + private readonly commandOverride?: string; + private readonly envCommandName: string; + private readonly log: (message: string) => void; + private activeProcess: ChildProcess | null = null; + + constructor(options: CodexRuntimeAdapterOptions = {}) { + this.commandOverride = options.command; + this.envCommandName = options.envCommandName ?? "OMI_CODEX_ADAPTER_COMMAND"; + this.log = options.log ?? (() => {}); + } + + async start(): Promise { + this.command(); + } + + async stop(): Promise { + if (!this.activeProcess) return; + const proc = this.activeProcess; + const exited = new Promise((resolve) => proc.once("exit", () => resolve())); + this.terminate(proc); + await exited; + } + + async openBinding(input: OpenBindingInput): Promise { + return this.binding(input); + } + + async resumeBinding(input: ResumeBindingInput): Promise { + return this.binding(input, input.adapterNativeSessionId); + } + + async executeAttempt( + context: AdapterAttemptContext, + sink: AdapterEventSink, + signal: AbortSignal + ): Promise { + const result = await this.runCodexExec(context, signal); + if (result.text) { + sink({ type: "text_delta", text: result.text }); + } + return { + text: result.text, + adapterSessionId: context.binding.adapterNativeSessionId, + terminalStatus: signal.aborted ? "cancelled" : "succeeded", + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + cacheReadTokens: result.cacheReadTokens, + cacheWriteTokens: result.cacheWriteTokens, + }; + } + + async cancelAttempt(_context: CancelAttemptContext): Promise { + if (!this.activeProcess) { + return { + accepted: true, + dispatchAttempted: false, + adapterAcknowledged: false, + message: "No Codex process is active", + }; + } + this.terminate(this.activeProcess); + return { + accepted: true, + dispatchAttempted: true, + adapterAcknowledged: false, + }; + } + + async closeBinding(_binding: AdapterBindingHandle): Promise { + } + + effectiveMcpServers(_mcpServers: Record[]): Record[] { + return []; + } + + private async runCodexExec( + context: AdapterAttemptContext, + signal: AbortSignal + ): Promise<{ + text: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }> { + const command = this.command(); + const args = [ + "exec", + "--json", + "--color", + "never", + "--skip-git-repo-check", + "--cd", + shellQuote(context.binding.cwd), + "-", + ].join(" "); + const fullCommand = `${command} ${args}`; + const prompt = promptText(context); + + return new Promise((resolve, reject) => { + const proc = spawn(fullCommand, { + shell: true, + cwd: context.binding.cwd, + env: this.codexEnv(), + stdio: ["pipe", "pipe", "pipe"], + detached: true, + }); + this.activeProcess = proc; + let stdout = ""; + let stderr = ""; + let settled = false; + + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + if (this.activeProcess === proc) this.activeProcess = null; + signal.removeEventListener("abort", abortHandler); + fn(); + }; + + const abortHandler = (): void => { + this.terminate(proc); + settle(() => reject(new Error("Codex command aborted"))); + }; + + if (signal.aborted) { + abortHandler(); + return; + } + signal.addEventListener("abort", abortHandler, { once: true }); + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data: Buffer) => { + const text = data.toString(); + stderr += text; + const trimmed = text.trim(); + if (trimmed) this.log(`codex stderr: ${trimmed}`); + }); + proc.on("error", (error) => settle(() => reject(error))); + proc.on("exit", (code) => { + if (code === 0) { + settle(() => resolve(resultFromJsonl(stdout))); + } else { + const diagnostic = stderr.trim() || stdout.trim(); + settle(() => reject(new Error(`Codex command exited with code ${code}: ${diagnostic}`))); + } + }); + + proc.stdin?.end(prompt); + }); + } + + private command(): string { + const command = this.commandOverride ?? process.env[this.envCommandName]; + if (!command?.trim()) { + throw new Error(`codex adapter requires ${this.envCommandName}`); + } + return command.trim(); + } + + private binding(input: OpenBindingInput, adapterNativeSessionId?: string): AdapterBindingHandle { + return { + bindingId: input.metadata?.bindingId as string | undefined, + sessionId: input.sessionId, + adapterId: this.adapterId, + adapterNativeSessionId: adapterNativeSessionId ?? `codex:${input.sessionId}`, + resumeFidelity: this.capabilities.resumeFidelity, + cwd: input.cwd, + model: input.model, + metadata: { + ...input.metadata, + systemPrompt: input.systemPrompt, + }, + }; + } + + private codexEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + OMI_ADAPTER_ID: this.adapterId, + }; + for (const key of CODEX_ADAPTER_ENV_ALLOWLIST) { + if (process.env[key] !== undefined) { + env[key] = PROXY_ENV_KEYS.has(key) + ? sanitizeProxyUrl(process.env[key]!) + : process.env[key]; + } + } + return env; + } + + private terminate(proc: ChildProcess): void { + try { + if (proc.pid) { + process.kill(-proc.pid, "SIGTERM"); + return; + } + } catch { + // Fall through to killing the shell process. + } + proc.kill("SIGTERM"); + } +} + +function promptText(context: AdapterAttemptContext): string { + const userPrompt = context.prompt + .filter( + (block): block is { type: "text"; text: string } => + block.type === "text" && typeof block.text === "string" + ) + .map((block) => block.text) + .join("\n"); + const systemPrompt = context.binding.metadata?.systemPrompt; + if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") { + return userPrompt; + } + return [ + "Omi system instructions:", + systemPrompt, + "", + "User task:", + userPrompt, + ].join("\n"); +} + +function resultFromJsonl(stdout: string): { + text: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} { + const messages: string[] = []; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + let cacheReadTokens: number | undefined; + let cacheWriteTokens: number | undefined; + const errors: string[] = []; + + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const event = parseJsonObject(trimmed); + if (!event) continue; + if (event.type === "error") { + const message = stringValue(event.message) ?? stringValue(event.error); + if (message) errors.push(message); + continue; + } + if (event.type === "item.completed" && isRecord(event.item)) { + const item = event.item; + if (item.type === "agent_message") { + const text = stringValue(item.text); + if (text) messages.push(text); + } + continue; + } + if ((event.type === "turn.completed" || event.type === "turn.failed") && isRecord(event.usage)) { + inputTokens = numberValue(event.usage.input_tokens ?? event.usage.input); + outputTokens = numberValue(event.usage.output_tokens ?? event.usage.output); + cacheReadTokens = numberValue(event.usage.cached_input_tokens ?? event.usage.cache_read_tokens); + cacheWriteTokens = numberValue(event.usage.cache_write_tokens); + } + if (event.type === "turn.failed") { + const message = stringValue(event.message) ?? stringValue(event.error); + if (message) errors.push(message); + } + } + + if (messages.length > 0) { + return { + text: messages.join("\n"), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + }; + } + if (errors.length > 0) { + throw new Error(`Codex failed: ${errors.join("; ")}`); + } + return { text: cleanStdout(stdout), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function cleanStdout(stdout: string): string { + return stdout + .split("\n") + .filter((line) => !/^\d{4}-\d{2}-\d{2}T.*\b(?:TRACE|DEBUG|INFO|WARN|ERROR)\b/.test(line)) + .join("\n") + .trim(); +} + +function parseJsonObject(text: string): Record | undefined { + try { + const parsed = JSON.parse(text) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function sanitizeProxyUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + return url.toString(); + } catch { + return value; + } +} diff --git a/desktop/macos/agent/src/adapters/interface.ts b/desktop/macos/agent/src/adapters/interface.ts index 4017f35e53a..cfed39aca44 100644 --- a/desktop/macos/agent/src/adapters/interface.ts +++ b/desktop/macos/agent/src/adapters/interface.ts @@ -254,6 +254,20 @@ export const ADAPTER_CAPABILITY_MATRIX = { restartOrphanSemantics: required("Startup reconciliation orphans active attempts while preserving native-resumable OpenClaw bindings."), }, }, + codex: { + adapterId: "codex", + productionAdapter: true, + expectations: { + nativeResume: unsupported("Phase 1 Codex routing uses `codex exec --json` without persisting native Codex thread ids for resume."), + cancellationDispatch: required("The Codex subprocess can be terminated for active attempts."), + cancellationAck: knownLimitation("Codex cancellation is process termination without an independent adapter ack.", "TICKET-codex-adapter-follow-up"), + pinnedWorker: unsupported("The Codex adapter launches one `codex exec` process per attempt and does not keep process-local session state."), + modelSwitching: unsupported("Phase 1 leaves model choice to the user's Codex CLI configuration."), + artifactEmission: unsupported("The Codex exec adapter does not emit artifact references yet."), + toolSupport: unsupported("Omi tool relay is not passed into `codex exec`; Codex uses its own CLI tools and configuration."), + restartOrphanSemantics: required("Startup reconciliation orphans active one-shot Codex attempts."), + }, + }, a2a: { adapterId: "a2a", productionAdapter: false, @@ -262,10 +276,10 @@ export const ADAPTER_CAPABILITY_MATRIX = { } as const satisfies Record; export type KnownAdapterId = keyof typeof ADAPTER_CAPABILITY_MATRIX; -export type ProductionAdapterId = "acp" | "pi-mono" | "hermes" | "openclaw"; +export type ProductionAdapterId = "acp" | "pi-mono" | "hermes" | "openclaw" | "codex"; export type PlaceholderAdapterId = Exclude; -export const PRODUCTION_ADAPTER_IDS = ["acp", "pi-mono", "hermes", "openclaw"] as const satisfies readonly ProductionAdapterId[]; +export const PRODUCTION_ADAPTER_IDS = ["acp", "pi-mono", "hermes", "openclaw", "codex"] as const satisfies readonly ProductionAdapterId[]; export const PLACEHOLDER_ADAPTER_IDS = ["a2a"] as const satisfies readonly PlaceholderAdapterId[]; export function isKnownAdapterId(adapterId: string): adapterId is KnownAdapterId { diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 57e88d35c7c..e2c9e0b632f 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -56,10 +56,14 @@ import { JsonlCompatibilityFacade, type McpServerBuildContext } from "./runtime/ import { AgentRuntimeKernel } from "./runtime/kernel.js"; import { resolveToolCallCorrelation } from "./runtime/tool-correlation.js"; import { + ADAPTER_ACTIVATION_ENV, adapterActivationError, adapterIdForHarnessMode, ensureRegisteredAdapter, + selectBestAdapterForTask, + type TaskExecutionAdapterId, } from "./runtime/adapter-selection.js"; +import { discoverAdapterCommand, type DiscoverableAdapterId } from "./runtime/adapter-discovery.js"; import { activeControlToolOwnerId, controlRequestKey, @@ -695,12 +699,15 @@ function controlRunAdapterId(name: string, input: Record, defau if (name !== "send_agent_message" && name !== "delegate_agent") { return undefined; } - const adapterId = typeof input.adapterId === "string" && input.adapterId.trim() ? input.adapterId.trim() : undefined; - const defaultFromInput = - typeof input.defaultAdapterId === "string" && input.defaultAdapterId.trim() ? input.defaultAdapterId.trim() : undefined; + const adapterId = normalizeOptionalString(input.adapterId); + const defaultFromInput = normalizeOptionalString(input.defaultAdapterId); return adapterId ?? defaultFromInput ?? defaultAdapterId; } +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function isLongLivedControlRun(name: string, input: Record): boolean { return name === "delegate_agent" && input.mode === "spawn"; } @@ -911,7 +918,18 @@ async function main(): Promise { }; const piMonoAvailable = await ensurePiMonoAdapter(process.env.OMI_AUTH_TOKEN); + // Swift seeds OMI_*_ADAPTER_COMMAND at bridge start; re-discover at query + // time so agents installed mid-session (install-help flow) become available + // without a bridge restart. + const discoverExternalAdapter = (adapterId: DiscoverableAdapterId): void => { + const alreadyConfigured = Boolean(process.env[ADAPTER_ACTIVATION_ENV[adapterId]]?.trim()); + const command = discoverAdapterCommand(adapterId); + if (!alreadyConfigured && command) { + logErr(`Adapter command discovered at query time id=${adapterId}`); + } + }; const ensureHermesAdapter = async (): Promise => { + discoverExternalAdapter("hermes"); return ensureRegisteredAdapter(registry, "hermes", { log: logErr, maxWorkers: 1, @@ -919,14 +937,52 @@ async function main(): Promise { }); }; const ensureOpenClawAdapter = async (): Promise => { + discoverExternalAdapter("openclaw"); return ensureRegisteredAdapter(registry, "openclaw", { log: logErr, maxWorkers: configuredPiMonoMaxWorkers(), onCreate: (adapter) => localAcpAdapters.add(adapter), }); }; + const ensureCodexAdapter = async (): Promise => { + discoverExternalAdapter("codex"); + return ensureRegisteredAdapter(registry, "codex", { + log: logErr, + maxWorkers: 1, + onCreate: (adapter) => localAcpAdapters.add(adapter), + }); + }; + const ensureAdapterForQuery = async (adapterId: string): Promise => { + if (adapterId === "acp") { + await startAcpProcess(); + await initializeAcp(); + return true; + } + if (adapterId === "pi-mono") { + return ensurePiMonoAdapter(process.env.OMI_AUTH_TOKEN); + } + if (adapterId === "hermes") { + return ensureHermesAdapter(); + } + if (adapterId === "openclaw") { + return ensureOpenClawAdapter(); + } + if (adapterId === "codex") { + return ensureCodexAdapter(); + } + return registry.has(adapterId); + }; + const connectedTaskAdapterIds = async (): Promise => { + const connected: TaskExecutionAdapterId[] = []; + if (defaultAdapterId === "acp" && registry.has("acp")) connected.push("acp"); + if (await ensureHermesAdapter()) connected.push("hermes"); + if (await ensureOpenClawAdapter()) connected.push("openclaw"); + if (await ensureCodexAdapter()) connected.push("codex"); + return connected; + }; const hermesAvailable = await ensureHermesAdapter(); const openClawAvailable = await ensureOpenClawAdapter(); + const codexAvailable = await ensureCodexAdapter(); if (!piMonoAvailable && defaultAdapterId === "pi-mono") { const msg = "pi-mono mode requires OMI_AUTH_TOKEN (Firebase ID token); refusing to start"; logErr(msg); @@ -945,6 +1001,12 @@ async function main(): Promise { send({ type: "error", message: msg }); process.exit(1); } + if (!codexAvailable && defaultAdapterId === "codex") { + const msg = adapterActivationError("codex") ?? "Codex adapter is unavailable."; + logErr(msg); + send({ type: "error", message: msg }); + process.exit(1); + } agentControlToolContext = { kernel, getOwnerId: () => currentOwnerId, @@ -997,7 +1059,24 @@ async function main(): Promise { case "query": (async () => { const query = msg as QueryMessage; - const adapterId = query.adapterId ?? defaultAdapterId; + const explicitAdapterId = normalizeOptionalString(query.adapterId); + const selection = explicitAdapterId + ? undefined + : selectBestAdapterForTask({ + prompt: query.prompt, + defaultAdapterId, + connectedAdapterIds: await connectedTaskAdapterIds(), + }); + const adapterId = explicitAdapterId ?? selection!.adapterId; + query.adapterId = adapterId; + query.fallbackAdapterIds = explicitAdapterId ? [] : selection!.fallbackAdapterIds; + query.adapterAutoSelected = !explicitAdapterId; + query.adapterSelectionReason = selection?.reason; + if (selection) { + logErr( + `Adapter auto-selection selected=${selection.adapterId} reason=${selection.reason} taskKind=${selection.taskKind} codeLike=${selection.codeLike} connected=${selection.connectedAdapterIds.join(",") || "none"} fallback=${selection.fallbackAdapterIds.join(",") || "none"}` + ); + } if (query.protocolVersion === 2 && !query.clientId?.trim()) { throw new Error("protocol v2 query requires clientId"); } @@ -1014,18 +1093,20 @@ async function main(): Promise { const insertedOwner = queryOwnerKey ? registerActiveControlOwner(queryOwnerKey, queryOwnerId) : false; currentOwnerId = queryOwnerId; try { - if (adapterId === "acp") { - await startAcpProcess(); - await initializeAcp(); - } else if (adapterId === "pi-mono") { - await ensurePiMonoAdapter(process.env.OMI_AUTH_TOKEN); - } else if (adapterId === "hermes") { - if (!(await ensureHermesAdapter())) { - throw new Error(adapterActivationError("hermes")); - } - } else if (adapterId === "openclaw") { - if (!(await ensureOpenClawAdapter())) { - throw new Error(adapterActivationError("openclaw")); + if (!(await ensureAdapterForQuery(adapterId))) { + const activationMessage = + adapterId === "acp" || + adapterId === "pi-mono" || + adapterId === "hermes" || + adapterId === "openclaw" || + adapterId === "codex" + ? adapterActivationError(adapterId) + : undefined; + throw new Error(activationMessage ?? `Adapter is not available: ${adapterId}`); + } + for (const fallbackAdapterId of query.fallbackAdapterIds ?? []) { + if (!(await ensureAdapterForQuery(fallbackAdapterId))) { + query.fallbackAdapterIds = (query.fallbackAdapterIds ?? []).filter((candidate) => candidate !== fallbackAdapterId); } } await facade.handleQuery(query); diff --git a/desktop/macos/agent/src/legacy-permission-policy.ts b/desktop/macos/agent/src/legacy-permission-policy.ts index 6c36742da16..bca32232f9f 100644 --- a/desktop/macos/agent/src/legacy-permission-policy.ts +++ b/desktop/macos/agent/src/legacy-permission-policy.ts @@ -14,7 +14,7 @@ export interface LegacyPermissionDecision { }; auditEvent: { type: "approval.resolved"; - policy: "legacy_high_trust" | "external_constrained"; + policy: "legacy_high_trust" | "external_constrained" | "external_autonomous"; adapterId: string; requestId?: number | string; optionId: string; @@ -23,6 +23,25 @@ export interface LegacyPermissionDecision { }; } +/** + * External ACP adapters whose underlying agent is trusted to run its own tools + * autonomously (terminal, file edits) the same way OpenClaw already does. + * + * OpenClaw's backend self-authorizes tool calls inside its own subprocess and + * never sends `session/request_permission` to us — so its tools always execute + * and the `external_constrained` deny path is never reached. Hermes, by + * contrast, follows the ACP permission handshake strictly and asks us before + * every tool call, so under `external_constrained` (which prefers a deny/reject + * option) it gets auto-denied and can never act. + * + * Listing both here gives Hermes the same effective autonomy OpenClaw has: when + * one of these adapters does request permission, we auto-approve it rather than + * deny. `openclaw` is included for parity/future-proofing (a no-op today since + * it never asks). Any other/unknown external adapter stays on the conservative + * `external_constrained` path and is not affected. + */ +export const AUTONOMOUS_EXTERNAL_ADAPTERS: ReadonlySet = new Set(["hermes", "openclaw"]); + export interface LegacyPermissionRejection { acpError: { code: number; @@ -75,6 +94,38 @@ export class LegacyPermissionPolicy { requestId?: number | string; options: LegacyPermissionOption[]; }): LegacyPermissionDecision | LegacyPermissionRejection { + // Adapters trusted to run tools autonomously (see AUTONOMOUS_EXTERNAL_ADAPTERS) + // get the same effective auto-approval OpenClaw already enjoys: prefer an + // allow option, and never fall through to a deny/reject. + if (AUTONOMOUS_EXTERNAL_ADAPTERS.has(input.adapterId)) { + const allow = + input.options.find((option) => option.kind === "allow_once") ?? + input.options.find((option) => option.kind === "allow_always") ?? + input.options.find((option) => !/deny|reject|disallow/i.test(option.kind)) ?? + input.options[0] ?? + { kind: "legacy_fallback", optionId: "allow" }; + + return { + optionId: allow.optionId, + optionKind: allow.kind, + acpResult: { + outcome: { + outcome: "selected", + optionId: allow.optionId, + }, + }, + auditEvent: { + type: "approval.resolved", + policy: "external_autonomous", + adapterId: input.adapterId, + requestId: input.requestId, + optionId: allow.optionId, + optionKind: allow.kind, + automatic: true, + }, + }; + } + const selected = input.options.find((option) => /deny|reject|disallow/i.test(option.kind)) ?? input.options.find((option) => option.kind === "allow_once") ?? diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index d1332b4cd38..b471a9779c2 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -29,6 +29,9 @@ export interface QueryMessage extends ProtocolEnvelope, CanonicalCorrelation { prompt: string; systemPrompt: string; adapterId?: string; + fallbackAdapterIds?: string[]; + adapterAutoSelected?: boolean; + adapterSelectionReason?: string; surfaceKind?: string; externalRefKind?: string; externalRefId?: string; @@ -126,6 +129,7 @@ export interface OutboundEnvelope { } export interface QueryScopedOutbound extends OutboundEnvelope, CanonicalCorrelation { + adapterId?: string; adapterSessionId?: string; legacyAdapterSessionId?: string; } diff --git a/desktop/macos/agent/src/runtime/adapter-discovery.ts b/desktop/macos/agent/src/runtime/adapter-discovery.ts new file mode 100644 index 00000000000..fabd7bf6db0 --- /dev/null +++ b/desktop/macos/agent/src/runtime/adapter-discovery.ts @@ -0,0 +1,108 @@ +/** + * Query-time discovery of user-installed external adapter CLIs. + * + * Swift seeds OMI_{HERMES,OPENCLAW,CODEX}_ADAPTER_COMMAND into the bridge + * environment at process start (AgentRuntimeProcess.applyLocalAgentEnvironment), + * but that snapshot goes stale when an agent is installed mid-session — e.g. + * through the floating-bar install-help flow. The bridge process keeps running, + * so without a re-scan the freshly installed agent stays "not available" until + * the app restarts. This module mirrors the Swift detector's search directories + * and command construction so the ensure*Adapter paths can pick up new installs + * without a bridge restart. + */ + +import { accessSync, constants } from "fs"; +import { homedir } from "os"; +import { dirname, join } from "path"; +import { ADAPTER_ACTIVATION_ENV } from "./adapter-selection.js"; + +export type DiscoverableAdapterId = "hermes" | "openclaw" | "codex"; + +const ADAPTER_EXECUTABLE_NAMES: Record = { + hermes: "hermes", + openclaw: "openclaw", + codex: "codex", +}; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function isExecutableFile(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Same search order as Swift's LocalAgentProviderDetector / AgentRuntimeProcess. */ +function adapterSearchDirectories(env: NodeJS.ProcessEnv): string[] { + const home = env.HOME?.trim() || homedir(); + const candidates = [ + ...(env.PATH ?? "").split(":"), + join(home, ".hermes", "hermes-agent", "venv", "bin"), + join(home, ".hermes", "node", "bin"), + join(home, ".hermes", "hermes-agent"), + join(home, ".openclaw", "bin"), + join(home, ".openclaw", "node", "bin"), + join(home, ".local", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + ]; + const seen = new Set(); + const result: string[] = []; + for (const dir of candidates) { + if (!dir || seen.has(dir)) continue; + seen.add(dir); + result.push(dir); + } + return result; +} + +function firstExecutable(name: string, env: NodeJS.ProcessEnv): string | undefined { + for (const dir of adapterSearchDirectories(env)) { + const path = join(dir, name); + if (isExecutableFile(path)) return path; + } + return undefined; +} + +/** Mirrors AgentRuntimeProcess's per-adapter command construction. */ +export function adapterCommandForExecutable( + adapterId: DiscoverableAdapterId, + executablePath: string +): string { + if (adapterId === "codex") { + return shellQuote(executablePath); + } + if (adapterId === "openclaw") { + const siblingNode = join(dirname(executablePath), "node"); + if (isExecutableFile(siblingNode)) { + return `${shellQuote(siblingNode)} ${shellQuote(executablePath)} acp`; + } + return `${shellQuote(executablePath)} acp`; + } + return `${shellQuote(executablePath)} acp`; +} + +/** + * Ensure the activation env var for an external adapter is populated. + * Returns the command if the adapter is (or becomes) activated, else undefined. + * Mutates `env` in place when a fresh install is discovered so subsequent + * adapterIsActivated checks and adapter start() calls see the command. + */ +export function discoverAdapterCommand( + adapterId: DiscoverableAdapterId, + env: NodeJS.ProcessEnv = process.env +): string | undefined { + const envName = ADAPTER_ACTIVATION_ENV[adapterId]; + const existing = env[envName]?.trim(); + if (existing) return existing; + const executablePath = firstExecutable(ADAPTER_EXECUTABLE_NAMES[adapterId], env); + if (!executablePath) return undefined; + const command = adapterCommandForExecutable(adapterId, executablePath); + env[envName] = command; + return command; +} diff --git a/desktop/macos/agent/src/runtime/adapter-selection.ts b/desktop/macos/agent/src/runtime/adapter-selection.ts index 2d4fca06c79..caa8910d746 100644 --- a/desktop/macos/agent/src/runtime/adapter-selection.ts +++ b/desktop/macos/agent/src/runtime/adapter-selection.ts @@ -1,4 +1,5 @@ import { AcpRuntimeAdapter } from "../adapters/acp.js"; +import { CodexRuntimeAdapter } from "../adapters/codex.js"; import { HermesRuntimeAdapter } from "../adapters/hermes.js"; import { OpenClawRuntimeAdapter } from "../adapters/openclaw.js"; import { adapterCapabilitiesFor, type AdapterCapabilities, type ProductionAdapterId, type RuntimeAdapter } from "../adapters/interface.js"; @@ -9,9 +10,46 @@ export const ADAPTER_ACTIVATION_ENV = { "pi-mono": "OMI_AUTH_TOKEN", hermes: "OMI_HERMES_ADAPTER_COMMAND", openclaw: "OMI_OPENCLAW_ADAPTER_COMMAND", + codex: "OMI_CODEX_ADAPTER_COMMAND", } as const; export type SelectableAdapterId = keyof typeof ADAPTER_ACTIVATION_ENV; +export const TASK_EXECUTION_ADAPTER_IDS = ["acp", "hermes", "openclaw", "codex"] as const; +export type TaskExecutionAdapterId = typeof TASK_EXECUTION_ADAPTER_IDS[number]; + +const DEEP_CODEBASE_TASK_ADAPTER_PRIORITY = ["acp", "codex", "hermes", "openclaw"] as const satisfies readonly TaskExecutionAdapterId[]; +const TERMINAL_NATIVE_TASK_ADAPTER_PRIORITY = ["codex", "acp", "hermes", "openclaw"] as const satisfies readonly TaskExecutionAdapterId[]; +const STRAIGHTFORWARD_CODE_TASK_ADAPTER_PRIORITY = ["codex", "acp", "hermes", "openclaw"] as const satisfies readonly TaskExecutionAdapterId[]; +const GENERAL_TASK_ADAPTER_PRIORITY = ["hermes", "openclaw", "codex", "acp"] as const satisfies readonly TaskExecutionAdapterId[]; + +const CODE_RELATED_KEYWORD_PATTERN = + /\b(bug|debug|fix|implement|implementation|function|method|class|struct|interface|test|tests|refactor|compile|build|lint|typescript|javascript|swift|python|rust|code|repo|stack trace|exception|regression|endpoint)\b/i; +const FILE_NAME_PATTERN = + /\b[\w.-]+\.(ts|tsx|js|jsx|mjs|cjs|swift|py|rs|go|java|kt|kts|m|mm|h|hpp|cpp|c|cs|dart|rb|php|json|ya?ml|toml|sql|sh|mdx?)\b/i; +const FILE_PATH_PATTERN = /(^|[\s"'(])(?:\.{1,2}\/|~\/|\/|[A-Za-z0-9_.-]+\/)[^\s"'`]+\.[A-Za-z0-9]{1,8}(?=$|[\s"'),.:;])/; +const INLINE_CODE_PATTERN = /```|`[^`]*(?:=>|function|class|def|import|const|let|var|return|\{|\})[^`]*`/i; +const CODE_SYMBOL_PATTERN = /\b(function|func|class|struct|enum|interface|def|async|await|import|export|return|throws?)\b|[{};]\s*$/im; +const CODE_IDENTIFIER_PATTERN = + /\b(?:[A-Za-z_$][\w$]*_[\w$]+|[a-z_$][\w$]*\d[\w$]*[A-Z][\w$]*|[a-z_$][\w$]*[A-Z][\w$]*\d[\w$]*)\b/; +const CODEBASE_SCOPE_PATTERN = + /\b(?:multi[-\s]?file|codebase[-\s]?wide|repo(?:sitory)?[-\s]?wide|project[-\s]?wide|cross[-\s]?(?:file|module|service)|across\s+(?:the\s+)?(?:codebase|repo(?:sitory)?|project|files?|modules?|services?|packages?)|across\s+(?:these\s+|the\s+)?(?:\w+\s+){0,4}(?:files?|modules?|services?|packages?))\b/i; +const DEEP_CODE_REASONING_PATTERN = + /\b(?:trace|root cause|debug(?:ging)?|hard bug|race condition|deadlock|regression|architecture|architectural|plan|planning|design|understand\s+how|interact(?:ion)?|data flow|control flow|call graph|dependency graph|decouple|restructure|redesign|modulari[sz]e)\b/i; +const TERMINAL_NATIVE_TASK_PATTERN = + /\b(?:ci\/cd|ci|github actions?|workflow|pipeline|docker|kubernetes|k8s|deploy(?:ment)?|release|migration|migrations|dependency|dependencies|lockfile|package-lock|pnpm-lock|yarn\.lock|npm|pnpm|yarn|brew|shell|bash|zsh|terminal|cli|script|makefile|generate\s+(?:docs?|documentation|readme|changelog)|documentation|readme|changelog)\b/i; +const RUN_TECHNICAL_ARTIFACT_PATTERN = + /\b(?:run|execute|apply)\b[\s\S]{0,80}\b(?:script|command|migration|migrations|test|tests|build|lint|typecheck|workflow|pipeline)\b/i; + +export type AdapterSelectionTaskKind = "deep_codebase" | "terminal_devops" | "straightforward_code" | "general"; + +export interface AdapterAutoSelectionResult { + adapterId: string; + fallbackAdapterIds: TaskExecutionAdapterId[]; + reason: string; + codeLike: boolean; + taskKind: AdapterSelectionTaskKind; + connectedAdapterIds: TaskExecutionAdapterId[]; +} export interface AdapterProfile { adapterId: ProductionAdapterId; @@ -52,6 +90,13 @@ export const ADAPTER_PROFILES: Record = { capabilities: adapterCapabilitiesFor("openclaw"), createAdapter: ({ log }) => new OpenClawRuntimeAdapter({ log }), }, + codex: { + adapterId: "codex", + activationEnv: ADAPTER_ACTIVATION_ENV.codex, + maxWorkers: 1, + capabilities: adapterCapabilitiesFor("codex"), + createAdapter: ({ log }) => new CodexRuntimeAdapter({ log }), + }, }; export function adapterIdForHarnessMode(harnessMode: string | undefined): SelectableAdapterId { @@ -65,6 +110,8 @@ export function adapterIdForHarnessMode(harnessMode: string | undefined): Select case "openclaw": case "openClaw": return "openclaw"; + case "codex": + return "codex"; case "acp": return "acp"; default: @@ -88,10 +135,83 @@ export function adapterProfile(adapterId: ProductionAdapterId): AdapterProfile { return ADAPTER_PROFILES[adapterId]; } +export function taskTextLooksCodeRelated(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return ( + CODE_RELATED_KEYWORD_PATTERN.test(trimmed) || + FILE_NAME_PATTERN.test(trimmed) || + FILE_PATH_PATTERN.test(trimmed) || + INLINE_CODE_PATTERN.test(trimmed) || + CODE_SYMBOL_PATTERN.test(trimmed) || + CODE_IDENTIFIER_PATTERN.test(trimmed) + ); +} + +function taskTextLooksDeepCodebaseRelated(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return CODEBASE_SCOPE_PATTERN.test(trimmed) || (DEEP_CODE_REASONING_PATTERN.test(trimmed) && taskTextLooksCodeRelated(trimmed)); +} + +function taskTextLooksTerminalNative(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return TERMINAL_NATIVE_TASK_PATTERN.test(trimmed) || RUN_TECHNICAL_ARTIFACT_PATTERN.test(trimmed); +} + +export function classifyTaskForAdapterSelection(text: string): AdapterSelectionTaskKind { + if (taskTextLooksDeepCodebaseRelated(text)) return "deep_codebase"; + if (taskTextLooksTerminalNative(text)) return "terminal_devops"; + if (taskTextLooksCodeRelated(text)) return "straightforward_code"; + return "general"; +} + +export function selectBestAdapterForTask(input: { + prompt: string; + defaultAdapterId: string; + connectedAdapterIds: readonly TaskExecutionAdapterId[]; +}): AdapterAutoSelectionResult { + const codeLike = taskTextLooksCodeRelated(input.prompt); + const taskKind = classifyTaskForAdapterSelection(input.prompt); + const priority = + taskKind === "deep_codebase" + ? DEEP_CODEBASE_TASK_ADAPTER_PRIORITY + : taskKind === "terminal_devops" + ? TERMINAL_NATIVE_TASK_ADAPTER_PRIORITY + : taskKind === "straightforward_code" + ? STRAIGHTFORWARD_CODE_TASK_ADAPTER_PRIORITY + : GENERAL_TASK_ADAPTER_PRIORITY; + const connected = new Set(input.connectedAdapterIds); + const connectedAdapterIds = TASK_EXECUTION_ADAPTER_IDS.filter((adapterId) => connected.has(adapterId)); + const selectedAdapterId = priority.find((adapterId) => connected.has(adapterId)); + if (!selectedAdapterId) { + return { + adapterId: input.defaultAdapterId, + fallbackAdapterIds: [], + reason: "default_no_connected_task_adapters", + codeLike, + taskKind, + connectedAdapterIds, + }; + } + return { + adapterId: selectedAdapterId, + fallbackAdapterIds: priority.filter((adapterId) => adapterId !== selectedAdapterId && connected.has(adapterId)), + reason: `${taskKind}_task_${selectedAdapterId}`, + codeLike, + taskKind, + connectedAdapterIds, + }; +} + export function adapterActivationError(adapterId: ProductionAdapterId): string | undefined { const envName = adapterActivationEnv(adapterId); if (!envName) return undefined; - const label = adapterId === "pi-mono" ? "pi-mono" : adapterId === "openclaw" ? "OpenClaw" : "Hermes"; + const label = adapterId === "pi-mono" ? "pi-mono" : adapterId === "openclaw" ? "OpenClaw" : adapterId === "codex" ? "Codex" : "Hermes"; + if (adapterId === "codex") { + return "Codex is not available. Install the Codex CLI, sign in, then try again."; + } if (adapterId === "hermes" || adapterId === "openclaw") { return `${label} is not available. Make sure ${label} is installed first, then try again.`; } diff --git a/desktop/macos/agent/src/runtime/compatibility-facade.ts b/desktop/macos/agent/src/runtime/compatibility-facade.ts index d41885f3ec1..7f0b19b507d 100644 --- a/desktop/macos/agent/src/runtime/compatibility-facade.ts +++ b/desktop/macos/agent/src/runtime/compatibility-facade.ts @@ -443,6 +443,11 @@ export class JsonlCompatibilityFacade { const mode = message.mode ?? "act"; const requestedAdapterId = message.adapterId ?? this.defaultAdapterId; const requestedModel = message.model ?? this.defaultModel(requestedAdapterId); + const fallbackAdapterIds = Array.isArray(message.fallbackAdapterIds) + ? message.fallbackAdapterIds + .filter((adapterId): adapterId is string => typeof adapterId === "string" && adapterId.trim().length > 0) + .map((adapterId) => adapterId.trim()) + : []; const legacySessionKey = message.legacySessionKey ?? message.sessionKey ?? requestedModel; const hint = legacySessionKey ? this.warmupHints.get(legacySessionKey) : undefined; const cwd = message.cwd ?? hint?.cwd ?? this.defaultCwd(); @@ -478,10 +483,14 @@ export class JsonlCompatibilityFacade { }), legacyAdapterSessionId: message.legacyAdapterSessionId ?? message.resume, maxAttempts: this.maxRecoverableRetries > 0 ? this.maxRecoverableRetries + 1 : undefined, + fallbackAdapterIds, recoverAfterError: this.recoverAfterError(), metadata: { protocolVersion: message.protocolVersion ?? 1, legacyAdapterSessionId: message.legacyAdapterSessionId ?? message.resume, + adapterAutoSelected: message.adapterAutoSelected === true, + adapterSelectionReason: message.adapterSelectionReason, + fallbackAdapterIds, source: "jsonl_compatibility_facade", }, }; @@ -559,6 +568,18 @@ export class JsonlCompatibilityFacade { if (event.attemptId) { context.attemptId = event.attemptId; } + if (typeof payload.adapterId === "string") { + context.adapterId = payload.adapterId; + } + if (event.type === "run.adapter_fallback") { + this.log( + `Adapter fallback run=${event.runId} from=${payload.fromAdapterId ?? "?"} to=${payload.toAdapterId ?? "?"} reason=${payload.reason ?? "unknown"}` + ); + } else if (event.type === "run.adapter_fallback_skipped") { + this.log( + `Adapter fallback skipped run=${event.runId} adapter=${payload.adapterId ?? "?"} reason=${payload.reason ?? "unknown"}` + ); + } if (event.type === "attempt.started" || event.type === "run.running") { context.isRunning = true; } @@ -636,6 +657,7 @@ export class JsonlCompatibilityFacade { runId: context.runId, attemptId: context.attemptId, eventId: context.eventId, + adapterId: context.adapterId, adapterSessionId: context.adapterSessionId ?? message.adapterSessionId, legacyAdapterSessionId: context.legacyAdapterSessionId, }; diff --git a/desktop/macos/agent/src/runtime/failures.ts b/desktop/macos/agent/src/runtime/failures.ts index 05c30251ceb..c7259ba8415 100644 --- a/desktop/macos/agent/src/runtime/failures.ts +++ b/desktop/macos/agent/src/runtime/failures.ts @@ -12,6 +12,9 @@ export interface RuntimeFailure { retryable?: boolean; } +type ClassifiedRuntimeFailure = Pick & + Partial>; + export class AdapterRuntimeError extends Error { readonly failure: RuntimeFailure; @@ -95,7 +98,7 @@ export function failureFromProcessExit(input: { function classifyAdapterProcessFailure( adapterId: ProductionAdapterId, diagnostic: string -): Partial | undefined { +): ClassifiedRuntimeFailure | undefined { if (adapterId === "openclaw" && isOpenClawInvalidConfig(diagnostic)) { return { code: "adapter_config_invalid", @@ -151,6 +154,8 @@ function adapterFailureLabel(adapterId: ProductionAdapterId, provider?: string): return "OpenClaw"; case "hermes": return "Hermes"; + case "codex": + return "Codex"; case "pi-mono": return "pi-mono"; case "acp": diff --git a/desktop/macos/agent/src/runtime/kernel.ts b/desktop/macos/agent/src/runtime/kernel.ts index 747f18fa66d..9fd28229a1a 100644 --- a/desktop/macos/agent/src/runtime/kernel.ts +++ b/desktop/macos/agent/src/runtime/kernel.ts @@ -154,6 +154,7 @@ export interface ExecuteAgentRunInput extends KernelSessionResolutionInput { mcpServers?: Record[]; legacyAdapterSessionId?: string; maxAttempts?: number; + fallbackAdapterIds?: string[]; tools?: ToolDef[]; metadata?: Record; parentRunId?: string; @@ -487,14 +488,64 @@ export class AgentRuntimeKernel { input: ExecuteAgentRunInput, accepted: { session: AgentSession; run: AgentRun } ): Promise { - - const adapterId = input.adapterId ?? accepted.session.defaultAdapterId; + const initialAdapterId = input.adapterId ?? accepted.session.defaultAdapterId; + const fallbackAdapterIds = (input.fallbackAdapterIds ?? []) + .map((adapterId) => adapterId.trim()) + .filter((adapterId, index, all) => + adapterId.length > 0 && + adapterId !== initialAdapterId && + all.indexOf(adapterId) === index + ); const maxAttempts = Math.max(1, input.maxAttempts ?? 2); + let currentAdapterId = initialAdapterId; + let primaryAttemptsUsed = 0; + let fallbackAttemptsUsed = 0; + let attemptNo = 0; let retryReason: string | null = null; let resumeFromAttemptId: string | null = null; let lastAttempt: RunAttempt | undefined; - for (let attemptNo = 1; attemptNo <= maxAttempts; attemptNo += 1) { + while (primaryAttemptsUsed < maxAttempts || (currentAdapterId !== initialAdapterId && fallbackAttemptsUsed < 1)) { + const isFallbackAttempt = currentAdapterId !== initialAdapterId; + if (isFallbackAttempt) { + fallbackAttemptsUsed += 1; + } else { + primaryAttemptsUsed += 1; + } + attemptNo += 1; + const adapterId = currentAdapterId; + const canRetryCurrentAdapter = !isFallbackAttempt && primaryAttemptsUsed < maxAttempts; + const fallbackAdapterId = () => + !isFallbackAttempt && fallbackAttemptsUsed === 0 + ? fallbackAdapterIds.find((candidate) => candidate !== adapterId && this.registry.has(candidate)) + : undefined; + const prepareFallback = (attempt: RunAttempt, reason: string, errorMessage?: string | null): boolean => { + const nextAdapterId = fallbackAdapterId(); + if (!nextAdapterId) return false; + if (this.attemptDispatchedOmiToolUse(attempt.attemptId)) { + this.appendAdapterFallbackSkipped({ + sessionId: accepted.session.sessionId, + runId: accepted.run.runId, + attemptId: attempt.attemptId, + adapterId, + reason: "side_effectful_tool_use", + }); + return false; + } + this.appendAdapterFallbackEvent({ + sessionId: accepted.session.sessionId, + runId: accepted.run.runId, + attemptId: attempt.attemptId, + fromAdapterId: adapterId, + toAdapterId: nextAdapterId, + reason, + errorMessage, + }); + currentAdapterId = nextAdapterId; + retryReason = `adapter_fallback:${reason}`; + resumeFromAttemptId = attempt.attemptId; + return true; + }; const attempt = this.createAttempt({ runId: accepted.run.runId, attemptNo, @@ -517,9 +568,12 @@ export class AgentRuntimeKernel { attempt, "adapter_not_registered", failure.userMessage, - false, + Boolean(fallbackAdapterId()), failure ); + if (prepareFallback(attempt, "adapter_not_registered", failure.userMessage)) { + continue; + } break; } const pool = this.registry.get(adapterId); @@ -573,14 +627,20 @@ export class AgentRuntimeKernel { code: "stale_binding", source: "adapter_process", adapterId: attempt.adapterId, - retryable: attemptNo < maxAttempts, + retryable: canRetryCurrentAdapter || Boolean(fallbackAdapterId()), }); - this.failAttemptBeforeExecution(attempt, "stale_binding", failure.userMessage, attemptNo < maxAttempts, failure); + this.failAttemptBeforeExecution(attempt, "stale_binding", failure.userMessage, failure.retryable === true, failure); + if (!canRetryCurrentAdapter && prepareFallback(attempt, "stale_binding", failure.userMessage)) { + continue; + } retryReason = "stale_binding"; resumeFromAttemptId = attempt.attemptId; - continue; + if (canRetryCurrentAdapter) { + continue; + } + break; } - if (await this.tryRecoverAttempt(input, attempt, error, "binding_failed", attemptNo < maxAttempts)) { + if (await this.tryRecoverAttempt(input, attempt, error, "binding_failed", canRetryCurrentAdapter)) { retryReason = "recoverable_error"; resumeFromAttemptId = attempt.attemptId; continue; @@ -589,9 +649,12 @@ export class AgentRuntimeKernel { code: "binding_failed", source: "adapter_process", adapterId: attempt.adapterId, - retryable: false, + retryable: Boolean(fallbackAdapterId()), }); - this.failAttemptBeforeExecution(attempt, "binding_failed", failure.userMessage, false, failure); + this.failAttemptBeforeExecution(attempt, "binding_failed", failure.userMessage, failure.retryable === true, failure); + if (prepareFallback(attempt, "binding_failed", failure.userMessage)) { + continue; + } break; } @@ -643,6 +706,17 @@ export class AgentRuntimeKernel { ); }); this.activeExecutions.delete(accepted.run.runId); + if (result.terminalStatus === "failed" && prepareFallback(attempt, result.failure?.code ?? "adapter_execution_failed", result.failure?.userMessage ?? result.text)) { + this.finishAttemptForRetry({ + attempt, + status: "failed", + errorCode: result.failure?.code ?? "adapter_execution_failed", + errorMessage: result.failure?.userMessage ?? result.text, + failure: result.failure, + result, + }); + continue; + } return this.completeAttemptAndRun(accepted.session, accepted.run.runId, attempt, binding, result); } catch (error) { this.activeExecutions.delete(accepted.run.runId); @@ -652,14 +726,20 @@ export class AgentRuntimeKernel { code: "stale_binding", source: "adapter_execution", adapterId: attempt.adapterId, - retryable: attemptNo < maxAttempts, + retryable: canRetryCurrentAdapter || Boolean(fallbackAdapterId()), }); - this.failAttemptBeforeExecution(attempt, "stale_binding", failure.userMessage, attemptNo < maxAttempts, failure); + this.failAttemptBeforeExecution(attempt, "stale_binding", failure.userMessage, failure.retryable === true, failure); + if (!canRetryCurrentAdapter && prepareFallback(attempt, "stale_binding", failure.userMessage)) { + continue; + } retryReason = "stale_binding"; resumeFromAttemptId = attempt.attemptId; - continue; + if (canRetryCurrentAdapter) { + continue; + } + break; } - if (await this.tryRecoverAttempt(input, attempt, error, "adapter_execution_failed", attemptNo < maxAttempts)) { + if (await this.tryRecoverAttempt(input, attempt, error, "adapter_execution_failed", canRetryCurrentAdapter)) { retryReason = "recoverable_error"; resumeFromAttemptId = attempt.attemptId; continue; @@ -670,8 +750,18 @@ export class AgentRuntimeKernel { code: "adapter_execution_failed", source: "adapter_execution", adapterId: attempt.adapterId, - retryable: false, + retryable: !wasCancelling && Boolean(fallbackAdapterId()), }); + if (!wasCancelling && failure && prepareFallback(attempt, "adapter_execution_failed", failure.userMessage)) { + this.finishAttemptForRetry({ + attempt, + status: "failed", + errorCode: "adapter_execution_failed", + errorMessage: failure.userMessage, + failure, + }); + continue; + } this.finishAttemptAndRun({ sessionId: accepted.session.sessionId, runId: accepted.run.runId, @@ -1276,6 +1366,7 @@ export class AgentRuntimeKernel { payload: { attemptId: attempt.attemptId, attemptNo: attempt.attemptNo, + adapterId: input.adapterId, retryReason: input.retryReason, resumeFromAttemptId: input.resumeFromAttemptId, }, @@ -1528,14 +1619,14 @@ export class AgentRuntimeKernel { runId: attempt.runId, attemptId: attempt.attemptId, type: "attempt.started", - payload: { attemptId: attempt.attemptId, bindingId: binding.bindingId }, + payload: { attemptId: attempt.attemptId, bindingId: binding.bindingId, adapterId: attempt.adapterId }, }); this.appendEvent({ sessionId: run.sessionId, runId: attempt.runId, attemptId: attempt.attemptId, type: "run.running", - payload: { runId: attempt.runId, attemptId: attempt.attemptId }, + payload: { runId: attempt.runId, attemptId: attempt.attemptId, adapterId: attempt.adapterId }, }); }); } @@ -1591,6 +1682,39 @@ export class AgentRuntimeKernel { }; } + private finishAttemptForRetry(input: { + attempt: RunAttempt; + status: AttemptStatus; + errorCode: string | null; + errorMessage: string | null; + failure?: RuntimeFailure | null; + result?: AdapterAttemptResult; + }): void { + const run = this.readRun(input.attempt.runId); + this.withTransaction(() => { + this.updateAttempt(input.attempt.attemptId, { + status: input.status, + retryable: 1, + completedAtMs: Date.now(), + errorCode: input.errorCode, + errorMessage: input.errorMessage, + updatedAtMs: Date.now(), + }); + this.appendEvent({ + sessionId: run.sessionId, + runId: input.attempt.runId, + attemptId: input.attempt.attemptId, + type: input.status === "cancelled" ? "attempt.cancelled" : "attempt.failed", + payload: { + attemptId: input.attempt.attemptId, + status: input.status, + retryable: true, + failure: input.failure ?? input.result?.failure, + }, + }); + }); + } + private finishAttemptAndRun(input: { sessionId: string; runId: string; @@ -1738,6 +1862,67 @@ export class AgentRuntimeKernel { return true; } + private appendAdapterFallbackEvent(input: { + sessionId: string; + runId: string; + attemptId: string; + fromAdapterId: string; + toAdapterId: string; + reason: string; + errorMessage?: string | null; + }): void { + this.appendEvent({ + sessionId: input.sessionId, + runId: input.runId, + attemptId: input.attemptId, + type: "run.adapter_fallback", + payload: { + runId: input.runId, + failedAttemptId: input.attemptId, + fromAdapterId: input.fromAdapterId, + toAdapterId: input.toAdapterId, + reason: input.reason, + errorMessage: input.errorMessage ?? null, + }, + }); + } + + private appendAdapterFallbackSkipped(input: { + sessionId: string; + runId: string; + attemptId: string; + adapterId: string; + reason: string; + }): void { + this.appendEvent({ + sessionId: input.sessionId, + runId: input.runId, + attemptId: input.attemptId, + type: "run.adapter_fallback_skipped", + payload: { + runId: input.runId, + failedAttemptId: input.attemptId, + adapterId: input.adapterId, + reason: input.reason, + }, + }); + } + + private attemptDispatchedOmiToolUse(attemptId: string): boolean { + const rows = this.store.allRows( + "SELECT payload_json FROM events WHERE attempt_id = ? AND type IN (?, ?, ?)", + [attemptId, "tool.started", "tool.completed", "tool.failed"], + ); + return rows.some((row) => { + try { + const payload = JSON.parse(String(row.payload_json)) as { type?: unknown }; + return payload.type === "tool_use"; + } catch { + return false; + } + }); + } + private persistAdapterEvent(sessionId: string, runId: string, attemptId: string, event: OutboundMessage): void { if (this.isTerminalAttempt(attemptId) || this.isTerminalRun(runId)) { return; @@ -2520,7 +2705,15 @@ function mcpServersForBinding( } const normalized: Record = { ...server }; const env = Array.isArray(normalized.env) ? normalized.env : []; - normalized.env = upsertEnv(env, "OMI_CONTEXT_FILE", contextFileForBinding(sessionId, adapterId, runtimeNodeId)); + const nextEnv = upsertEnv(env, "OMI_CONTEXT_FILE", contextFileForBinding(sessionId, adapterId, runtimeNodeId)); + normalized.env = env.some((entry) => + entry && + typeof entry === "object" && + !Array.isArray(entry) && + (entry as Record).name === "OMI_BRIDGE_PIPE" + ) + ? upsertEnv(nextEnv, "OMI_ADAPTER_ID", adapterId) + : nextEnv; return normalized; }); } diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index fe663bc5093..b693ecda704 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -270,7 +270,7 @@ export const swiftToolManifest: OmiToolManifestEntry[] = [ promptGuidelines: [ "Calling spawn_agent is the only way to start the circular floating-bar subagent; saying you will start one does not start it.", "Use delegate_agent instead for canonical Omi child sessions/runs that need durable delegation tracking.", - "If the user asks to use OpenClaw or Hermes, pass provider='openclaw' or provider='hermes' instead of treating that name as a session to inspect.", + "If the user asks to use OpenClaw, Hermes, or Codex, pass provider='openclaw', provider='hermes', or provider='codex' instead of treating that name as a session to inspect.", "Return immediately after spawning; the pill keeps working in the background.", ], latency: "async background", @@ -280,7 +280,7 @@ export const swiftToolManifest: OmiToolManifestEntry[] = [ title: { type: "string", description: "Short Title Case label for the agent pill." }, provider: { type: "string", - enum: ["openclaw", "hermes"], + enum: ["openclaw", "hermes", "codex"], description: "Optional local agent provider to run this pill through.", }, }, @@ -828,6 +828,19 @@ export function toolNamesForAdapter( return toolsForAdapter(adapterId, context).map((tool) => tool.adapters[adapterId]?.adapterName ?? tool.name); } +/** + * The Anthropic API rejects custom tool input_schema with top-level + * oneOf/allOf/anyOf (and if/then), and the stdio MCP tools are forwarded + * verbatim to it by the Claude Code SDK on the acp adapter path. Strip those + * combinators at this projection boundary; the same constraints remain stated + * in each tool's description/prompt guidelines and are enforced by the tool + * executor at call time. + */ +function anthropicSafeInputSchema(schema: OmiMcpToolInputSchema): OmiMcpToolInputSchema { + const { anyOf: _anyOf, allOf: _allOf, oneOf: _oneOf, if: _if, then: _then, ...rest } = schema; + return rest; +} + export function mcpToolDefinitionsForAdapter( adapterId: "omi-tools-stdio", context: OmiToolProjectionContext = {}, @@ -835,7 +848,7 @@ export function mcpToolDefinitionsForAdapter( return toolsForAdapter(adapterId, context).map((tool) => ({ name: tool.adapters[adapterId]?.adapterName ?? tool.name, description: tool.description, - inputSchema: tool.mcpInputSchema ?? tool.inputSchema, + inputSchema: anthropicSafeInputSchema(tool.mcpInputSchema ?? tool.inputSchema), })); } diff --git a/desktop/macos/agent/tests/adapter-discovery.test.ts b/desktop/macos/agent/tests/adapter-discovery.test.ts new file mode 100644 index 00000000000..3bc9ab949c4 --- /dev/null +++ b/desktop/macos/agent/tests/adapter-discovery.test.ts @@ -0,0 +1,64 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + adapterCommandForExecutable, + discoverAdapterCommand, +} from "../src/runtime/adapter-discovery.js"; + +describe("adapter discovery", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "adapter-discovery-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + function installExecutable(relativeDir: string, name: string): string { + const dir = join(home, relativeDir); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, "#!/bin/sh\nexit 0\n"); + chmodSync(path, 0o755); + return path; + } + + it("returns the existing env command without searching", () => { + const env = { HOME: home, PATH: "", OMI_HERMES_ADAPTER_COMMAND: "'/usr/bin/hermes' acp" }; + expect(discoverAdapterCommand("hermes", env)).toBe("'/usr/bin/hermes' acp"); + }); + + it("returns undefined when the executable is missing", () => { + const env = { HOME: home, PATH: "" }; + expect(discoverAdapterCommand("hermes", env)).toBeUndefined(); + expect(env.OMI_HERMES_ADAPTER_COMMAND).toBeUndefined(); + }); + + it("discovers a freshly installed hermes and seeds the env command", () => { + const hermes = installExecutable(".local/bin", "hermes"); + const env: NodeJS.ProcessEnv = { HOME: home, PATH: "" }; + expect(discoverAdapterCommand("hermes", env)).toBe(`'${hermes}' acp`); + expect(env.OMI_HERMES_ADAPTER_COMMAND).toBe(`'${hermes}' acp`); + }); + + it("discovers codex on PATH without an acp suffix", () => { + const codex = installExecutable("bin", "codex"); + const env: NodeJS.ProcessEnv = { HOME: home, PATH: join(home, "bin") }; + expect(discoverAdapterCommand("codex", env)).toBe(`'${codex}'`); + }); + + it("prefers a sibling node binary for openclaw", () => { + const openclaw = installExecutable(".openclaw/bin", "openclaw"); + const node = installExecutable(".openclaw/bin", "node"); + expect(adapterCommandForExecutable("openclaw", openclaw)).toBe(`'${node}' '${openclaw}' acp`); + }); + + it("falls back to direct openclaw invocation without a sibling node", () => { + const openclaw = installExecutable(".openclaw/bin", "openclaw"); + expect(adapterCommandForExecutable("openclaw", openclaw)).toBe(`'${openclaw}' acp`); + }); +}); diff --git a/desktop/macos/agent/tests/adapter-selection.test.ts b/desktop/macos/agent/tests/adapter-selection.test.ts index 1dc64db9a34..3a38899f1f6 100644 --- a/desktop/macos/agent/tests/adapter-selection.test.ts +++ b/desktop/macos/agent/tests/adapter-selection.test.ts @@ -6,6 +6,9 @@ import { adapterIdForHarnessMode, adapterIsActivated, adapterProfile, + classifyTaskForAdapterSelection, + selectBestAdapterForTask, + taskTextLooksCodeRelated, } from "../src/runtime/adapter-selection.js"; describe("adapter selection and activation", () => { @@ -17,6 +20,7 @@ describe("adapter selection and activation", () => { expect(adapterIdForHarnessMode("hermes")).toBe("hermes"); expect(adapterIdForHarnessMode("openclaw")).toBe("openclaw"); expect(adapterIdForHarnessMode("openClaw")).toBe("openclaw"); + expect(adapterIdForHarnessMode("codex")).toBe("codex"); expect(() => adapterIdForHarnessMode("unknown")).toThrow("Unknown harness mode: unknown"); }); @@ -25,12 +29,14 @@ describe("adapter selection and activation", () => { expect(adapterActivationEnv("pi-mono")).toBe("OMI_AUTH_TOKEN"); expect(adapterActivationEnv("hermes")).toBe("OMI_HERMES_ADAPTER_COMMAND"); expect(adapterActivationEnv("openclaw")).toBe("OMI_OPENCLAW_ADAPTER_COMMAND"); + expect(adapterActivationEnv("codex")).toBe("OMI_CODEX_ADAPTER_COMMAND"); expect(adapterIsActivated("acp", {})).toBe(true); expect(adapterIsActivated("hermes", {})).toBe(false); expect(adapterIsActivated("hermes", { OMI_HERMES_ADAPTER_COMMAND: " " })).toBe(false); expect(adapterIsActivated("hermes", { OMI_HERMES_ADAPTER_COMMAND: "hermes-adapter" })).toBe(true); expect(adapterIsActivated("openclaw", { OMI_OPENCLAW_ADAPTER_COMMAND: "openclaw-adapter" })).toBe(true); + expect(adapterIsActivated("codex", { OMI_CODEX_ADAPTER_COMMAND: "codex" })).toBe(true); }); it("centralizes production adapter profiles and capabilities", () => { @@ -49,6 +55,11 @@ describe("adapter selection and activation", () => { activationEnv: "OMI_OPENCLAW_ADAPTER_COMMAND", capabilities: { supportsTools: false, supportsModelSwitching: false }, }); + expect(adapterProfile("codex")).toMatchObject({ + adapterId: "codex", + activationEnv: "OMI_CODEX_ADAPTER_COMMAND", + capabilities: { supportsTools: false, supportsModelSwitching: false }, + }); expect(adapterActivationError("hermes")).toBe( "Hermes is not available. Make sure Hermes is installed first, then try again." ); @@ -57,17 +68,145 @@ describe("adapter selection and activation", () => { "OpenClaw is not available. Make sure OpenClaw is installed first, then try again." ); expect(adapterActivationError("openclaw")).not.toContain("OMI_OPENCLAW_ADAPTER_COMMAND"); + expect(adapterActivationError("codex")).toBe( + "Codex is not available. Install the Codex CLI, sign in, then try again." + ); + expect(adapterActivationError("codex")).not.toContain("OMI_CODEX_ADAPTER_COMMAND"); + }); + + it("detects code-like task text with simple keywords and path patterns", () => { + expect(taskTextLooksCodeRelated("Fix the failing test in agent/src/runtime/kernel.ts")).toBe(true); + expect(taskTextLooksCodeRelated("Can you implement a function for parsing dates?")).toBe(true); + expect(taskTextLooksCodeRelated("Please refactor this class")).toBe(true); + expect(taskTextLooksCodeRelated("phase2AutoSelectionCheck is failing")).toBe(true); + expect(taskTextLooksCodeRelated("Draft a concise meeting agenda for tomorrow")).toBe(false); + expect(taskTextLooksCodeRelated("Summarize Mission 1400 progress")).toBe(false); + }); + + it("classifies task shape before choosing among connected adapters", () => { + expect(classifyTaskForAdapterSelection("Trace this bug across the codebase and refactor the interaction between modules")).toBe( + "deep_codebase" + ); + expect(classifyTaskForAdapterSelection("Run the migration script and update the CI workflow")).toBe("terminal_devops"); + expect(classifyTaskForAdapterSelection("Implement a function for parsing dates")).toBe("straightforward_code"); + expect(classifyTaskForAdapterSelection("Draft a concise meeting agenda for tomorrow")).toBe("general"); + }); + + it("prefers ACP for deep codebase tasks and Codex for terminal-native tasks when connected", () => { + expect( + selectBestAdapterForTask({ + prompt: "Trace this bug across the codebase and refactor the interaction between modules", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["acp", "hermes", "openclaw", "codex"], + }), + ).toMatchObject({ + adapterId: "acp", + fallbackAdapterIds: ["codex", "hermes", "openclaw"], + reason: "deep_codebase_task_acp", + taskKind: "deep_codebase", + codeLike: true, + }); + + expect( + selectBestAdapterForTask({ + prompt: "Run the migration script and update the CI dependency lockfile", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["acp", "hermes", "openclaw", "codex"], + }), + ).toMatchObject({ + adapterId: "codex", + fallbackAdapterIds: ["acp", "hermes", "openclaw"], + reason: "terminal_devops_task_codex", + taskKind: "terminal_devops", + }); + }); + + it("uses only connected adapters even when the preferred adapter is unavailable", () => { + expect( + selectBestAdapterForTask({ + prompt: "Refactor this to be cleaner across these three files", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["codex"], + }), + ).toMatchObject({ + adapterId: "codex", + fallbackAdapterIds: [], + reason: "deep_codebase_task_codex", + taskKind: "deep_codebase", + connectedAdapterIds: ["codex"], + }); + + expect( + selectBestAdapterForTask({ + prompt: "Run this migration script", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["codex"], + }), + ).toMatchObject({ + adapterId: "codex", + fallbackAdapterIds: [], + reason: "terminal_devops_task_codex", + taskKind: "terminal_devops", + connectedAdapterIds: ["codex"], + }); + }); + + it("auto-selects Codex for straightforward code tasks and Hermes first for general tasks", () => { + expect( + selectBestAdapterForTask({ + prompt: "Fix the bug in Desktop/Sources/Chat/AgentBridge.swift", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["acp", "hermes", "openclaw", "codex"], + }), + ).toMatchObject({ + adapterId: "codex", + fallbackAdapterIds: ["acp", "hermes", "openclaw"], + reason: "straightforward_code_task_codex", + taskKind: "straightforward_code", + codeLike: true, + }); + + expect( + selectBestAdapterForTask({ + prompt: "Summarize my notes and draft a follow-up", + defaultAdapterId: "pi-mono", + connectedAdapterIds: ["acp", "hermes", "openclaw", "codex"], + }), + ).toMatchObject({ + adapterId: "hermes", + fallbackAdapterIds: ["openclaw", "codex", "acp"], + reason: "general_task_hermes", + taskKind: "general", + codeLike: false, + }); + }); + + it("falls through to the default adapter when no task-execution adapters are connected", () => { + expect( + selectBestAdapterForTask({ + prompt: "Fix the bug in kernel.ts", + defaultAdapterId: "pi-mono", + connectedAdapterIds: [], + }), + ).toMatchObject({ + adapterId: "pi-mono", + fallbackAdapterIds: [], + reason: "default_no_connected_task_adapters", + }); }); - it("source: daemon registers Hermes/OpenClaw explicitly and does not stamp MCP env as ACP", () => { + it("source: daemon registers Hermes/OpenClaw/Codex explicitly and does not stamp MCP env as ACP", () => { const indexSource = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8"); expect(indexSource).toContain("adapterIdForHarnessMode(defaultHarnessMode)"); expect(indexSource).toContain('defaultAdapterId === "acp"'); expect(indexSource).toContain("ensureRegisteredAdapter(registry, \"hermes\""); expect(indexSource).toContain("ensureRegisteredAdapter(registry, \"openclaw\""); + expect(indexSource).toContain("ensureRegisteredAdapter(registry, \"codex\""); + expect(indexSource).toContain('if (defaultAdapterId === "acp" && registry.has("acp")) connected.push("acp")'); expect(indexSource).toContain('adapterActivationError("hermes")'); expect(indexSource).toContain('adapterActivationError("openclaw")'); + expect(indexSource).toContain('adapterActivationError("codex")'); expect(indexSource).toContain("query.ownerId = queryOwnerId"); expect(indexSource).toContain('{ name: "OMI_ADAPTER_ID", value: context?.adapterId ?? "acp" }'); expect(indexSource).not.toContain('{ name: "OMI_ADAPTER_ID", value: "acp" }'); diff --git a/desktop/macos/agent/tests/kernel-fakes.ts b/desktop/macos/agent/tests/kernel-fakes.ts index 7c74aa28758..7013707401a 100644 --- a/desktop/macos/agent/tests/kernel-fakes.ts +++ b/desktop/macos/agent/tests/kernel-fakes.ts @@ -45,6 +45,8 @@ export class FakeRuntimeAdapter implements RuntimeAdapter { sinks = new Map(); failNextOpenError: unknown; failNextExecutionError: unknown; + failAfterToolUseError: unknown; + nextExecutionResult: AdapterAttemptResult | undefined; failNextResume = false; failNextExecutionAsStale = false; deferOnlyPromptIncludes: string | undefined; @@ -126,6 +128,18 @@ export class FakeRuntimeAdapter implements RuntimeAdapter { this.failNextExecutionError = undefined; throw error; } + if (this.failAfterToolUseError) { + sink({ + type: "tool_use", + callId: `tool-${context.attemptId}`, + name: "execute_sql", + input: { query: "select 1" }, + adapterSessionId: context.binding.adapterNativeSessionId, + }); + const error = this.failAfterToolUseError; + this.failAfterToolUseError = undefined; + throw error; + } const promptText = context.prompt .filter((block): block is Extract<(typeof context.prompt)[number], { type: "text" }> => block.type === "text") .map((block) => block.text) @@ -133,6 +147,14 @@ export class FakeRuntimeAdapter implements RuntimeAdapter { if (this.pendingResult && (!this.deferOnlyPromptIncludes || promptText.includes(this.deferOnlyPromptIncludes))) { return this.pendingResult.promise; } + if (this.nextExecutionResult) { + const result = this.nextExecutionResult; + this.nextExecutionResult = undefined; + return { + ...result, + adapterSessionId: result.adapterSessionId ?? context.binding.adapterNativeSessionId, + }; + } const artifacts = this.nextArtifacts; this.nextArtifacts = undefined; return { diff --git a/desktop/macos/agent/tests/legacy-permission-policy.test.ts b/desktop/macos/agent/tests/legacy-permission-policy.test.ts index 14e0c011f0c..5f997a80705 100644 --- a/desktop/macos/agent/tests/legacy-permission-policy.test.ts +++ b/desktop/macos/agent/tests/legacy-permission-policy.test.ts @@ -35,27 +35,56 @@ describe("LegacyPermissionPolicy", () => { expect(policy.resolveAcpPermission({ options: [] }).optionId).toBe("allow"); }); - it("keeps external ACP adapters off permanent auto-approval", () => { + it("auto-approves autonomous external adapters (hermes/openclaw) even when a deny option is offered", () => { const policy = new LegacyPermissionPolicy(); - const once = policy.resolveExternalAcpPermission({ - adapterId: "hermes", + // Real-world tool-call options include a reject variant; autonomous adapters + // must still pick the allow option (never the deny) so their tools execute. + for (const adapterId of ["hermes", "openclaw"]) { + const decision = policy.resolveExternalAcpPermission({ + adapterId, + requestId: 7, + options: [ + { kind: "allow_once", optionId: "once" }, + { kind: "reject_once", optionId: "deny" }, + ], + }); + expect("acpResult" in decision ? decision.acpResult.outcome.optionId : "").toBe("once"); + expect(decision.auditEvent).toMatchObject({ + type: "approval.resolved", + policy: "external_autonomous", + adapterId, + requestId: 7, + optionId: "once", + automatic: true, + }); + } + }); + + it("keeps unknown external ACP adapters on the conservative constrained path", () => { + const policy = new LegacyPermissionPolicy(); + + // A deny option is taken when present. + const denied = policy.resolveExternalAcpPermission({ + adapterId: "someexternal", options: [ - { kind: "allow_always", optionId: "always" }, { kind: "allow_once", optionId: "once" }, + { kind: "reject_once", optionId: "deny" }, ], }); - expect("acpResult" in once ? once.acpResult.outcome.optionId : "").toBe("once"); + expect("acpResult" in denied ? denied.acpResult.outcome.optionId : "").toBe("deny"); + expect(denied.auditEvent).toMatchObject({ policy: "external_constrained" }); + // Only a permanent-allow option => explicit rejection (no permanent auto-grant). const rejected = policy.resolveExternalAcpPermission({ - adapterId: "openclaw", + adapterId: "someexternal", requestId: 9, options: [{ kind: "allow_always", optionId: "always" }], }); expect("acpError" in rejected ? rejected.acpError.code : 0).toBe(-32001); expect(rejected.auditEvent).toMatchObject({ policy: "external_constrained", - adapterId: "openclaw", + adapterId: "someexternal", requestId: 9, }); }); diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index 0582b5a7de0..0349f780ce7 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -50,10 +50,11 @@ describe("omi tool manifest", () => { const spawnAgent = toolsForAdapter("pi-mono").find((tool) => tool.name === "spawn_agent"); expect(spawnAgent?.inputSchema.properties.provider).toMatchObject({ - enum: ["openclaw", "hermes"], + enum: ["openclaw", "hermes", "codex"], }); expect(spawnAgent?.promptGuidelines?.join("\n")).toContain("provider='openclaw'"); expect(spawnAgent?.promptGuidelines?.join("\n")).toContain("provider='hermes'"); + expect(spawnAgent?.promptGuidelines?.join("\n")).toContain("provider='codex'"); }); it("projects stdio onboarding-only tools only in onboarding context", () => { @@ -95,23 +96,19 @@ describe("omi tool manifest", () => { }); }); - it("preserves control-tool schema preconditions in MCP projections", () => { - const tools = mcpToolDefinitionsForAdapter("omi-tools-stdio"); - const inspectArtifacts = tools.find((tool) => tool.name === "inspect_agent_artifacts"); - const delegateAgent = tools.find((tool) => tool.name === "delegate_agent"); - - expect(inspectArtifacts?.inputSchema.anyOf).toEqual([ - { required: ["artifactId"] }, - { required: ["sessionId"] }, - { required: ["runId"] }, - { required: ["attemptId"] }, - ]); - expect(delegateAgent?.inputSchema.allOf).toEqual([ - { - if: { properties: { mode: { const: "continue" } }, required: ["mode"] }, - then: { required: ["childSessionId"] }, - }, - ]); + it("strips top-level schema combinators the Anthropic API rejects from MCP projections", () => { + // The Claude Code SDK forwards these schemas verbatim to the Anthropic + // API, which 400s on top-level oneOf/allOf/anyOf (observed live: + // "input_schema does not support oneOf, allOf, or anyOf at the top + // level"). The preconditions stay in prompt guidelines and are enforced + // by the executor at call time. + for (const tool of mcpToolDefinitionsForAdapter("omi-tools-stdio")) { + expect(tool.inputSchema.anyOf, `${tool.name} anyOf`).toBeUndefined(); + expect(tool.inputSchema.allOf, `${tool.name} allOf`).toBeUndefined(); + expect(tool.inputSchema.oneOf, `${tool.name} oneOf`).toBeUndefined(); + expect(tool.inputSchema.if, `${tool.name} if`).toBeUndefined(); + expect(tool.inputSchema.then, `${tool.name} then`).toBeUndefined(); + } }); it("keeps MCP-only schema options from overriding base tool schema fields", () => { diff --git a/desktop/macos/agent/tests/real-local-adapters.test.ts b/desktop/macos/agent/tests/real-local-adapters.test.ts index f92603e53f2..15c4e061aa8 100644 --- a/desktop/macos/agent/tests/real-local-adapters.test.ts +++ b/desktop/macos/agent/tests/real-local-adapters.test.ts @@ -107,10 +107,13 @@ describe("real local Hermes/OpenClaw adapter wrappers", () => { method: "session/request_permission", params: { options: [{ kind: "allow_always", optionId: "allow" }] }, })}\n`); - await vi.waitUntil(() => requests.some((request) => request.id === 99 && "error" in request)); + // Hermes is an autonomous external adapter (parity with OpenClaw, which + // self-executes tools), so its permission requests are auto-approved rather + // than rejected — the response selects the allow option. + await vi.waitUntil(() => requests.some((request) => request.id === 99 && "result" in request)); expect(requests.find((request) => request.id === 99)).toMatchObject({ - error: { - code: -32001, + result: { + outcome: { outcome: "selected", optionId: "allow" }, }, }); expect(spawn).toHaveBeenCalledWith( diff --git a/desktop/macos/agent/tests/run-attempt-lifecycle.test.ts b/desktop/macos/agent/tests/run-attempt-lifecycle.test.ts index 76bce18eeb3..7a2a06b2af2 100644 --- a/desktop/macos/agent/tests/run-attempt-lifecycle.test.ts +++ b/desktop/macos/agent/tests/run-attempt-lifecycle.test.ts @@ -2,7 +2,9 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { baseRunInput, createKernelHarness, waitUntil } from "./kernel-fakes.js"; +import { baseRunInput, createKernelHarness, FakeRuntimeAdapter, waitUntil } from "./kernel-fakes.js"; +import { AdapterRegistry } from "../src/runtime/adapter-registry.js"; +import { AgentRuntimeKernel } from "../src/runtime/kernel.js"; import { SqliteAgentStore } from "../src/runtime/sqlite-store.js"; const createdDirs: string[] = []; @@ -36,6 +38,124 @@ describe("AgentRuntimeKernel run and attempt lifecycle", () => { store.close(); }); + it("falls back once to the next connected adapter after selected adapter execution fails", async () => { + const store = new SqliteAgentStore({ databasePath: newDatabasePath(), reconcileOnOpen: false }); + const registry = new AdapterRegistry(); + const hermes = new FakeRuntimeAdapter("hermes"); + const openclaw = new FakeRuntimeAdapter("openclaw"); + hermes.failNextExecutionError = new Error("hermes crashed"); + registry.register("hermes", () => hermes, 1); + registry.register("openclaw", () => openclaw, 1); + const kernel = new AgentRuntimeKernel({ store, registry }); + + const result = await kernel.executeRun({ + ...baseRunInput, + adapterId: "hermes", + defaultAdapterId: "hermes", + fallbackAdapterIds: ["openclaw"], + maxAttempts: 1, + }); + + expect(result.run.status).toBe("succeeded"); + expect(result.attempt.adapterId).toBe("openclaw"); + expect(hermes.executed).toHaveLength(1); + expect(openclaw.executed).toHaveLength(1); + expect(store.allRows("SELECT adapter_id, status, retry_reason FROM run_attempts ORDER BY attempt_no")).toEqual([ + expect.objectContaining({ adapter_id: "hermes", status: "failed" }), + expect.objectContaining({ adapter_id: "openclaw", status: "succeeded", retry_reason: "adapter_fallback:adapter_execution_failed" }), + ]); + expect(JSON.parse(store.getRow("SELECT payload_json FROM events WHERE type = 'run.adapter_fallback'").payload_json)).toMatchObject({ + fromAdapterId: "hermes", + toAdapterId: "openclaw", + reason: "adapter_execution_failed", + }); + store.close(); + }); + + it("falls back once when the selected adapter returns a failed terminal result", async () => { + const store = new SqliteAgentStore({ databasePath: newDatabasePath(), reconcileOnOpen: false }); + const registry = new AdapterRegistry(); + const codex = new FakeRuntimeAdapter("codex"); + const hermes = new FakeRuntimeAdapter("hermes"); + codex.nextExecutionResult = { + text: "Codex failed", + adapterSessionId: "codex-native", + terminalStatus: "failed", + failure: { + code: "adapter_execution_failed", + source: "adapter_execution", + adapterId: "codex", + retryable: true, + userMessage: "Codex failed", + technicalMessage: "Codex failed", + }, + }; + registry.register("codex", () => codex, 1); + registry.register("hermes", () => hermes, 1); + const kernel = new AgentRuntimeKernel({ store, registry }); + + const result = await kernel.executeRun({ + ...baseRunInput, + adapterId: "codex", + defaultAdapterId: "codex", + fallbackAdapterIds: ["hermes"], + maxAttempts: 1, + }); + + expect(result.run.status).toBe("succeeded"); + expect(result.attempt.adapterId).toBe("hermes"); + expect(codex.executed).toHaveLength(1); + expect(hermes.executed).toHaveLength(1); + store.close(); + }); + + it("does not fall back when no fallback adapter ids are supplied", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "codex", 1); + adapter.failNextExecutionError = new Error("codex failed explicitly"); + + const result = await kernel.executeRun({ + ...baseRunInput, + adapterId: "codex", + defaultAdapterId: "codex", + maxAttempts: 1, + }); + + expect(result.run.status).toBe("failed"); + expect(adapter.executed).toHaveLength(1); + expect(store.allRows("SELECT adapter_id FROM run_attempts")).toEqual([ + expect.objectContaining({ adapter_id: "codex" }), + ]); + store.close(); + }); + + it("skips fallback after a Swift-backed Omi tool call was dispatched", async () => { + const store = new SqliteAgentStore({ databasePath: newDatabasePath(), reconcileOnOpen: false }); + const registry = new AdapterRegistry(); + const hermes = new FakeRuntimeAdapter("hermes"); + const openclaw = new FakeRuntimeAdapter("openclaw"); + hermes.failAfterToolUseError = new Error("failed after tool use"); + registry.register("hermes", () => hermes, 1); + registry.register("openclaw", () => openclaw, 1); + const kernel = new AgentRuntimeKernel({ store, registry }); + + const result = await kernel.executeRun({ + ...baseRunInput, + adapterId: "hermes", + defaultAdapterId: "hermes", + fallbackAdapterIds: ["openclaw"], + maxAttempts: 1, + }); + + expect(result.run.status).toBe("failed"); + expect(hermes.executed).toHaveLength(1); + expect(openclaw.executed).toHaveLength(0); + expect(JSON.parse(store.getRow("SELECT payload_json FROM events WHERE type = 'run.adapter_fallback_skipped'").payload_json)).toMatchObject({ + adapterId: "hermes", + reason: "side_effectful_tool_use", + }); + store.close(); + }); + it("replaces an active binding when cwd changes", async () => { const { store, adapter, kernel } = createKernelHarness(newDatabasePath()); diff --git a/desktop/macos/agent/tests/runtime-adapter.test.ts b/desktop/macos/agent/tests/runtime-adapter.test.ts index e90422bc04a..18beca5dde9 100644 --- a/desktop/macos/agent/tests/runtime-adapter.test.ts +++ b/desktop/macos/agent/tests/runtime-adapter.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { spawn } from "child_process"; import { AcpRuntimeAdapter } from "../src/adapters/acp.js"; +import { CodexRuntimeAdapter } from "../src/adapters/codex.js"; import { PiMonoAdapter, PiMonoRuntimeAdapter, @@ -330,6 +331,75 @@ describe("AcpRuntimeAdapter process spawning", () => { }); }); +describe("CodexRuntimeAdapter", () => { + beforeEach(() => { + vi.mocked(spawn).mockReset(); + }); + + it("runs codex exec JSONL through stdin and projects the final message", async () => { + const proc = createMockProcess(); + vi.mocked(spawn).mockReturnValue(proc as any); + let stdin = ""; + proc.stdin.on("data", (data: Buffer) => { + stdin += data.toString(); + }); + const adapter = new CodexRuntimeAdapter({ command: "codex" }); + const binding = await adapter.openBinding({ + sessionId: "omi-session", + cwd: "/repo path", + systemPrompt: "Follow Omi instructions.", + }); + const sink = vi.fn(); + + const resultPromise = adapter.executeAttempt({ + sessionId: "omi-session", + ownerId: "owner", + requestId: "request", + clientId: "client", + runId: "run", + attemptId: "attempt", + binding, + prompt: [{ type: "text", text: "Summarize this repo." }], + mode: "act", + }, sink, new AbortController().signal); + + proc.stdout.write(JSON.stringify({ type: "thread.started", thread_id: "thread-1" }) + "\n"); + proc.stdout.write(JSON.stringify({ + type: "item.completed", + item: { id: "item-1", type: "agent_message", text: "Repo summary." }, + }) + "\n"); + proc.stdout.write(JSON.stringify({ + type: "turn.completed", + usage: { input_tokens: 10, cached_input_tokens: 4, output_tokens: 5 }, + }) + "\n"); + proc.emit("exit", 0); + + const result = await resultPromise; + + expect(vi.mocked(spawn).mock.calls[0]).toMatchObject([ + "codex exec --json --color never --skip-git-repo-check --cd '/repo path' -", + expect.objectContaining({ + shell: true, + cwd: "/repo path", + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }), + ]); + expect(stdin).toContain("Omi system instructions:"); + expect(stdin).toContain("Follow Omi instructions."); + expect(stdin).toContain("Summarize this repo."); + expect(sink).toHaveBeenCalledWith({ type: "text_delta", text: "Repo summary." }); + expect(result).toMatchObject({ + text: "Repo summary.", + adapterSessionId: "codex:omi-session", + terminalStatus: "succeeded", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 4, + }); + }); +}); + describe("AcpRuntimeAdapter bindings", () => { beforeEach(() => { vi.mocked(spawn).mockReset(); @@ -478,7 +548,7 @@ describe("adapter capability matrix", () => { expect(Object.keys(ADAPTER_CAPABILITY_MATRIX).sort()).toEqual( [...PRODUCTION_ADAPTER_IDS, ...PLACEHOLDER_ADAPTER_IDS].sort() ); - expect(PRODUCTION_ADAPTER_IDS).toEqual(["acp", "pi-mono", "hermes", "openclaw"]); + expect(PRODUCTION_ADAPTER_IDS).toEqual(["acp", "pi-mono", "hermes", "openclaw", "codex"]); expect(PLACEHOLDER_ADAPTER_IDS).toEqual(["a2a"]); expect(ADAPTER_CAPABILITY_MATRIX.acp.expectations).toMatchObject({ @@ -521,6 +591,16 @@ describe("adapter capability matrix", () => { toolSupport: { status: "unsupported" }, restartOrphanSemantics: { status: "required" }, }); + expect(ADAPTER_CAPABILITY_MATRIX.codex.expectations).toMatchObject({ + nativeResume: { status: "unsupported" }, + cancellationDispatch: { status: "required" }, + cancellationAck: { status: "known_limitation", followUpTicket: "TICKET-codex-adapter-follow-up" }, + pinnedWorker: { status: "unsupported" }, + modelSwitching: { status: "unsupported" }, + artifactEmission: { status: "unsupported" }, + toolSupport: { status: "unsupported" }, + restartOrphanSemantics: { status: "required" }, + }); for (const adapterId of PLACEHOLDER_ADAPTER_IDS) { expect(ADAPTER_CAPABILITY_MATRIX[adapterId].productionAdapter).toBe(false); @@ -614,6 +694,17 @@ describe("adapter capability matrix", () => { supportsTools: false, restartBehavior: "native_bindings_survive", }); + expect(adapterCapabilitiesFor("codex")).toEqual({ + resumeFidelity: "none", + supportsNativeResume: false, + supportsCancellation: true, + acknowledgesCancellation: false, + requiresPinnedWorker: false, + supportsModelSwitching: false, + supportsArtifactEmission: false, + supportsTools: false, + restartBehavior: "attempts_orphaned", + }); }); }); diff --git a/desktop/macos/changelog/unreleased/20260701-ai-clone-accuracy.json b/desktop/macos/changelog/unreleased/20260701-ai-clone-accuracy.json new file mode 100644 index 00000000000..794cef2a718 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260701-ai-clone-accuracy.json @@ -0,0 +1,3 @@ +{ + "change": "Improved AI Clone reply accuracy with per-message example retrieval, measured style matching, and multi-candidate selection; burst replies now render as separate bubbles in Preview Chat" +} diff --git a/desktop/macos/changelog/unreleased/20260701-ai-clone-imessage-contacts.json b/desktop/macos/changelog/unreleased/20260701-ai-clone-imessage-contacts.json new file mode 100644 index 00000000000..e419da12b84 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260701-ai-clone-imessage-contacts.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone page now lists your real top iMessage contacts ranked by message count, with an auto-select-top-N control and per-contact selection" +} diff --git a/desktop/macos/changelog/unreleased/20260702-telegram-whatsapp-import.json b/desktop/macos/changelog/unreleased/20260702-telegram-whatsapp-import.json new file mode 100644 index 00000000000..e0fcfc3784d --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260702-telegram-whatsapp-import.json @@ -0,0 +1,3 @@ +{ + "change": "Added Telegram and WhatsApp chat import for AI Clone training" +} diff --git a/desktop/macos/changelog/unreleased/20260703-ai-clone-memory-calendar-context.json b/desktop/macos/changelog/unreleased/20260703-ai-clone-memory-calendar-context.json new file mode 100644 index 00000000000..06e263511a8 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260703-ai-clone-memory-calendar-context.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone replies can now draw on your saved Memories and real calendar availability when answering personal or scheduling questions (read-only — it never books or changes events)" +} diff --git a/desktop/macos/changelog/unreleased/20260703-ai-clone-whatsapp-send.json b/desktop/macos/changelog/unreleased/20260703-ai-clone-whatsapp-send.json new file mode 100644 index 00000000000..68f212daaae --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260703-ai-clone-whatsapp-send.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone can now send and receive WhatsApp messages — link your account by QR code from the AI Clone page (Autonomous mode for WhatsApp requires an extra one-time confirmation)" +} diff --git a/desktop/macos/changelog/unreleased/20260704-ai-clone-burst-sends.json b/desktop/macos/changelog/unreleased/20260704-ai-clone-burst-sends.json new file mode 100644 index 00000000000..8c0af766c30 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-ai-clone-burst-sends.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone multi-message replies now send as separate texts one at a time, like a real burst, instead of one message with line breaks" +} diff --git a/desktop/macos/changelog/unreleased/20260704-ai-clone-faster-replies.json b/desktop/macos/changelog/unreleased/20260704-ai-clone-faster-replies.json new file mode 100644 index 00000000000..6bf386c362b --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-ai-clone-faster-replies.json @@ -0,0 +1,3 @@ +{ + "change": "Made AI Clone replies noticeably faster by reusing one LLM connection instead of reconnecting on every call, and smarter by giving live replies the recent conversation as context" +} diff --git a/desktop/macos/changelog/unreleased/20260704-ai-clone-human-pacing.json b/desktop/macos/changelog/unreleased/20260704-ai-clone-human-pacing.json new file mode 100644 index 00000000000..9d6e547af2e --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-ai-clone-human-pacing.json @@ -0,0 +1,3 @@ +{ + "change": "Autonomous AI Clone replies now behave like a real person: a natural pause to read the message, a read receipt and live typing indicator on WhatsApp (typing on Telegram too), and typing time that scales with each reply" +} diff --git a/desktop/macos/changelog/unreleased/20260704-ai-clone-live-chat.json b/desktop/macos/changelog/unreleased/20260704-ai-clone-live-chat.json new file mode 100644 index 00000000000..4d81b489b8c --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-ai-clone-live-chat.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone: Chat now opens your real conversation — see the live thread, get clone-suggested replies you can edit and send, and message directly, with the old simulator in a Practice tab" +} diff --git a/desktop/macos/changelog/unreleased/20260704-ai-clone-works-without-page.json b/desktop/macos/changelog/unreleased/20260704-ai-clone-works-without-page.json new file mode 100644 index 00000000000..791b6327ffc --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-ai-clone-works-without-page.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed AI Clone Draft-Review and Autonomous modes not working unless the AI Clone page stayed open — the clone now listens for messages from app launch" +} diff --git a/desktop/macos/changelog/unreleased/20260704-clone-keeps-convos-going.json b/desktop/macos/changelog/unreleased/20260704-clone-keeps-convos-going.json new file mode 100644 index 00000000000..c757f2f0f85 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-clone-keeps-convos-going.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone replies now keep conversations going — they react in your voice and match the other person's energy instead of defaulting to dry, one-word answers" +} diff --git a/desktop/macos/changelog/unreleased/20260704-commitment-tracking.json b/desktop/macos/changelog/unreleased/20260704-commitment-tracking.json new file mode 100644 index 00000000000..3d3e0453c51 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-commitment-tracking.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone: 'Scan for Commitments' finds promises you made in your iMessage, Telegram, and WhatsApp history and turns them into Tasks so Omi can remind you" +} diff --git a/desktop/macos/changelog/unreleased/20260704-imessage-contact-names.json b/desktop/macos/changelog/unreleased/20260704-imessage-contact-names.json new file mode 100644 index 00000000000..aca2fca37a2 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260704-imessage-contact-names.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone now shows your real contact names instead of phone numbers everywhere — the contact list, chats, and commitment tasks all use the names from your Contacts" +} diff --git a/desktop/macos/changelog/unreleased/20260705-agent-auto-selection.json b/desktop/macos/changelog/unreleased/20260705-agent-auto-selection.json new file mode 100644 index 00000000000..ccf27ff34d4 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-agent-auto-selection.json @@ -0,0 +1,3 @@ +{ + "change": "Improved background agents to pick the best connected local agent automatically and fall back once when the first choice fails" +} diff --git a/desktop/macos/changelog/unreleased/20260705-agent-install-help.json b/desktop/macos/changelog/unreleased/20260705-agent-install-help.json new file mode 100644 index 00000000000..60d7acb7d51 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-agent-install-help.json @@ -0,0 +1,3 @@ +{ + "change": "Named agent requests now offer install help when Codex, Hermes, or OpenClaw is not connected" +} diff --git a/desktop/macos/changelog/unreleased/20260705-agent-selection-research.json b/desktop/macos/changelog/unreleased/20260705-agent-selection-research.json new file mode 100644 index 00000000000..80cd4a9cc2c --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-agent-selection-research.json @@ -0,0 +1,3 @@ +{ + "change": "Refined automatic agent selection to prefer Claude Code for deep codebase tasks and Codex for terminal-native work" +} diff --git a/desktop/macos/changelog/unreleased/20260705-block-people-from-tasks.json b/desktop/macos/changelog/unreleased/20260705-block-people-from-tasks.json new file mode 100644 index 00000000000..5f65a49e38a --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-block-people-from-tasks.json @@ -0,0 +1,3 @@ +{ + "change": "Added a 'Blocked People' setting under Task settings to stop specific contacts' messages from ever being turned into tasks" +} diff --git a/desktop/macos/changelog/unreleased/20260705-clone-stays-on-topic.json b/desktop/macos/changelog/unreleased/20260705-clone-stays-on-topic.json new file mode 100644 index 00000000000..ac9c493b0e9 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-clone-stays-on-topic.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone replies now stay on topic — they no longer tack on unrelated agenda or blurt out background facts to force a finished conversation to keep going, and they recognize natural conversation closers" +} diff --git a/desktop/macos/changelog/unreleased/20260705-codex-agent-routing.json b/desktop/macos/changelog/unreleased/20260705-codex-agent-routing.json new file mode 100644 index 00000000000..169e6a04cb1 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-codex-agent-routing.json @@ -0,0 +1,3 @@ +{ + "change": "Named agent requests can now route to Codex, Hermes, or OpenClaw from the floating bar and push-to-talk" +} diff --git a/desktop/macos/changelog/unreleased/20260705-tasks-from-messages.json b/desktop/macos/changelog/unreleased/20260705-tasks-from-messages.json new file mode 100644 index 00000000000..23b0124f179 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260705-tasks-from-messages.json @@ -0,0 +1,3 @@ +{ + "change": "Requests in your messages now become Tasks automatically — when someone (like a family member) asks you to do something, it's captured as a task instead of relying on a manual scan" +} diff --git a/desktop/macos/changelog/unreleased/20260706-agent-mention-install-flow.json b/desktop/macos/changelog/unreleased/20260706-agent-mention-install-flow.json new file mode 100644 index 00000000000..c690d60e6e0 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-agent-mention-install-flow.json @@ -0,0 +1,3 @@ +{ + "change": "Detect natural requests for specific local agents and guide disconnected agents through setup before retrying" +} diff --git a/desktop/macos/changelog/unreleased/20260706-agents-connect-without-restart.json b/desktop/macos/changelog/unreleased/20260706-agents-connect-without-restart.json new file mode 100644 index 00000000000..8dc8e802858 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-agents-connect-without-restart.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed agents installed mid-session (Hermes, OpenClaw, Codex) not being usable until Omi restarted" +} diff --git a/desktop/macos/changelog/unreleased/20260706-claude-code-agent-tasks.json b/desktop/macos/changelog/unreleased/20260706-claude-code-agent-tasks.json new file mode 100644 index 00000000000..99b6c97c8b7 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-claude-code-agent-tasks.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed Claude Code agent tasks failing with an API tool-schema error" +} diff --git a/desktop/macos/changelog/unreleased/20260706-connect-hermes-from-settings.json b/desktop/macos/changelog/unreleased/20260706-connect-hermes-from-settings.json new file mode 100644 index 00000000000..86ce3afd19c --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-connect-hermes-from-settings.json @@ -0,0 +1,3 @@ +{ + "change": "Added a Connect Hermes sign-in flow in Settings and in chat — when Hermes is installed but not signed in, Omi now opens the Nous sign-in page and waits for approval instead of failing with an internal error" +} diff --git a/desktop/macos/changelog/unreleased/20260706-hermes-agent-works-free-tier.json b/desktop/macos/changelog/unreleased/20260706-hermes-agent-works-free-tier.json new file mode 100644 index 00000000000..445809dd07a --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-hermes-agent-works-free-tier.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed the Hermes connected agent so it completes tasks out of the box — it now runs on a free Nous model (no paid credits needed) and its tool calls execute instead of being blocked" +} diff --git a/desktop/macos/changelog/unreleased/20260706-openclaw-auto-onboard.json b/desktop/macos/changelog/unreleased/20260706-openclaw-auto-onboard.json new file mode 100644 index 00000000000..d7e50978e1f --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260706-openclaw-auto-onboard.json @@ -0,0 +1,3 @@ +{ + "change": "OpenClaw now sets itself up automatically — after install the app runs OpenClaw's onboarding (gateway + your Claude sign-in) for you, so a fresh install becomes a working agent instead of silently doing nothing until you run onboarding by hand" +} diff --git a/desktop/macos/changelog/unreleased/20260707-clone-replies-instantly.json b/desktop/macos/changelog/unreleased/20260707-clone-replies-instantly.json new file mode 100644 index 00000000000..10b4823adf6 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260707-clone-replies-instantly.json @@ -0,0 +1,3 @@ +{ + "change": "AI Clone replies now send the moment they're generated — removed the simulated reading/typing delays" +} diff --git a/desktop/macos/changelog/unreleased/20260707-dashboard-grid-alignment.json b/desktop/macos/changelog/unreleased/20260707-dashboard-grid-alignment.json new file mode 100644 index 00000000000..0b4351dcd3f --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260707-dashboard-grid-alignment.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed the dashboard's What-Omi-Knows cards to form a clean grid — the AI Clone card no longer sits alone on its own row" +} diff --git a/desktop/macos/changelog/unreleased/20260707-openclaw-key-fallback.json b/desktop/macos/changelog/unreleased/20260707-openclaw-key-fallback.json new file mode 100644 index 00000000000..bcf602f494f --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260707-openclaw-key-fallback.json @@ -0,0 +1,3 @@ +{ + "change": "Added a bring-your-own-key path for connecting OpenClaw when Claude Code isn't set up — Omi opens Terminal with the setup command pre-typed and connects automatically once you finish" +} diff --git a/desktop/macos/whatsapp-sidecar/.gitignore b/desktop/macos/whatsapp-sidecar/.gitignore new file mode 100644 index 00000000000..c2658d7d1b3 --- /dev/null +++ b/desktop/macos/whatsapp-sidecar/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/desktop/macos/whatsapp-sidecar/package-lock.json b/desktop/macos/whatsapp-sidecar/package-lock.json new file mode 100644 index 00000000000..d555f78882d --- /dev/null +++ b/desktop/macos/whatsapp-sidecar/package-lock.json @@ -0,0 +1,1990 @@ +{ + "name": "omi-whatsapp-sidecar", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omi-whatsapp-sidecar", + "version": "1.0.0", + "dependencies": { + "@whiskeysockets/baileys": "6.7.23", + "pino": "^10.3.1", + "qrcode": "^1.5.4" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@whiskeysockets/baileys": { + "version": "6.7.23", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.23.tgz", + "integrity": "sha512-UDbysXJpFKHC2S27qy4oABQc2uZekhUcCNi+Nvzev7wZkOhC72wqezzM2cfYB3PHJdy3Jadc2VkVv8eQeywIUw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "axios": "^1.6.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "music-metadata": "^11.7.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/libsignal": { + "version": "6.0.0", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "^7.5.5" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/desktop/macos/whatsapp-sidecar/package.json b/desktop/macos/whatsapp-sidecar/package.json new file mode 100644 index 00000000000..868b8e22e3e --- /dev/null +++ b/desktop/macos/whatsapp-sidecar/package.json @@ -0,0 +1,15 @@ +{ + "name": "omi-whatsapp-sidecar", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "@whiskeysockets/baileys": "6.7.23", + "pino": "^10.3.1", + "qrcode": "^1.5.4" + } +} diff --git a/desktop/macos/whatsapp-sidecar/src/index.js b/desktop/macos/whatsapp-sidecar/src/index.js new file mode 100644 index 00000000000..106dcff3aa5 --- /dev/null +++ b/desktop/macos/whatsapp-sidecar/src/index.js @@ -0,0 +1,499 @@ +// Omi WhatsApp sidecar — a local-only Node process wrapping Baileys (the unofficial +// WhatsApp Web "Linked Devices" protocol). Spawned and owned by the desktop app's +// WhatsAppSendService; never exposed beyond 127.0.0.1. +// +// This connects a PERSONAL WhatsApp account through an unofficial library. It must never +// initiate anything on its own: every send is an explicit HTTP request from the app, which +// itself gates automated sends behind the AI Clone kill switch + per-contact modes. +// +// HTTP API (all JSON): +// GET /health → { ok, state } +// GET /link/status → { state, qrDataUrl?, phone? } state: unlinked | connecting | +// waiting_qr | linked | logged_out +// POST /link/start → begin linking (or resume a saved session); returns /link/status +// POST /send {to, text} → send a text message; `to` is digits or a full JID +// POST /read {to} → mark the latest incoming message from `to` as read (blue ticks) +// POST /presence {to, state}→ show "typing…" to `to` (state: composing | paused) +// GET /events?since=N → { events: [{seq, phone, fromMe, text, timestamp, senderName}], latest } +// GET /resolve?name=X → { phone, jid, name } or 404 — display-name → JID lookup +// POST /logout → unlink + clear the saved session +// +// Session persists under OMI_WA_SESSION_DIR (multi-file auth state), so linking survives +// restarts. The process tethers itself to the parent: it exits when stdin closes. + +import { createServer } from 'node:http' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import pino from 'pino' +import QRCode from 'qrcode' +import * as baileysModule from '@whiskeysockets/baileys' + +const baileys = baileysModule.default?.makeWASocket ? baileysModule.default : baileysModule +const makeWASocket = baileys.makeWASocket ?? baileys.default +const { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = baileys + +const PORT = Number(process.env.OMI_WA_PORT || 47790) +const TOKEN = process.env.OMI_WA_TOKEN || '' +const SESSION_DIR = + process.env.OMI_WA_SESSION_DIR || + join(homedir(), 'Library', 'Application Support', 'Omi', 'whatsapp-session') +const CONTACTS_FILE = join(SESSION_DIR, 'contacts.json') +const MAX_EVENTS = 500 + +const logger = pino({ level: process.env.OMI_WA_LOG_LEVEL || 'warn' }) + +// --------------------------------------------------------------------------- +// State + +/** @type {'unlinked'|'connecting'|'waiting_qr'|'linked'|'logged_out'} */ +let state = 'unlinked' +let sock = null +let latestQrDataUrl = null +let linkedPhone = null +let reconnectAttempts = 0 +let startPromise = null +let shuttingDown = false + +// Incoming-message ring buffer, consumed by the app via GET /events?since=. +let eventSeq = 0 +const events = [] + +// Latest incoming message key per 1:1 jid, so POST /read can ack ("blue tick") it. +/** @type {Map} */ +const lastIncomingKeyByJid = new Map() + +// Display-name → JID map for resolving imported contacts to real numbers. Seeded from the +// initial history sync, then kept fresh from contact upserts and message push-names. +/** @type {Map} */ +const contactsByJid = new Map() + +function loadContacts() { + try { + if (existsSync(CONTACTS_FILE)) { + for (const entry of JSON.parse(readFileSync(CONTACTS_FILE, 'utf8'))) { + if (entry?.jid && entry?.name) contactsByJid.set(entry.jid, entry) + } + } + } catch (err) { + logger.warn({ err: String(err) }, 'failed to load contacts cache') + } +} + +let contactsSaveTimer = null +function scheduleSaveContacts() { + if (contactsSaveTimer) return + contactsSaveTimer = setTimeout(() => { + contactsSaveTimer = null + try { + mkdirSync(SESSION_DIR, { recursive: true }) + writeFileSync(CONTACTS_FILE, JSON.stringify([...contactsByJid.values()])) + } catch (err) { + logger.warn({ err: String(err) }, 'failed to save contacts cache') + } + }, 2000) +} + +function rememberContact(jid, name) { + if (!jid || !name || !jid.endsWith('@s.whatsapp.net')) return + const trimmed = String(name).trim() + if (!trimmed) return + const existing = contactsByJid.get(jid) + if (existing?.name === trimmed) return + contactsByJid.set(jid, { jid, name: trimmed }) + scheduleSaveContacts() +} + +function phoneFromJid(jid) { + return String(jid || '').split('@')[0].split(':')[0] +} + +// --------------------------------------------------------------------------- +// Baileys socket lifecycle + +function hasSavedSession() { + return existsSync(join(SESSION_DIR, 'creds.json')) +} + +async function startSocket() { + if (startPromise) return startPromise + startPromise = (async () => { + try { + state = 'connecting' + latestQrDataUrl = null + mkdirSync(SESSION_DIR, { recursive: true }) + const { state: authState, saveCreds } = await useMultiFileAuthState(SESSION_DIR) + const { version } = await fetchLatestBaileysVersion().catch(() => ({ version: undefined })) + + sock = makeWASocket({ + version, + auth: authState, + logger: logger.child({ module: 'baileys' }), + printQRInTerminal: false, + markOnlineOnConnect: false, + syncFullHistory: false, + generateHighQualityLinkPreview: false, + browser: ['Omi Desktop', 'Desktop', '1.0.0'], + }) + + sock.ev.on('creds.update', saveCreds) + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update + if (qr) { + try { + latestQrDataUrl = await QRCode.toDataURL(qr, { margin: 1, width: 400 }) + state = 'waiting_qr' + } catch (err) { + logger.warn({ err: String(err) }, 'QR encode failed') + } + } + if (connection === 'open') { + state = 'linked' + latestQrDataUrl = null + reconnectAttempts = 0 + linkedPhone = phoneFromJid(sock?.user?.id) + logger.info({ phone: linkedPhone }, 'linked') + } + if (connection === 'close') { + const statusCode = lastDisconnect?.error?.output?.statusCode + const loggedOut = statusCode === DisconnectReason.loggedOut + sock = null + startPromise = null + if (shuttingDown || shuttingDownSocketOnly) return + if (loggedOut) { + // The phone unlinked this device — the saved session is dead. Clear it so the + // next /link/start produces a fresh QR instead of a reconnect loop. + clearSession() + state = 'logged_out' + linkedPhone = null + logger.info('logged out — session cleared') + return + } + // Transient close (network, server restart, QR timeout). Retry with backoff, but + // only keep retrying indefinitely when a saved session exists; a pending QR link + // gets a few attempts and then goes back to unlinked (the app can restart it). + const resumable = hasSavedSession() + reconnectAttempts += 1 + if (!resumable && reconnectAttempts > 3) { + state = 'unlinked' + latestQrDataUrl = null + logger.info('link attempt expired') + return + } + state = 'connecting' + const delay = Math.min(30000, 1000 * 2 ** Math.min(reconnectAttempts, 5)) + logger.info({ statusCode, delay }, 'connection closed — reconnecting') + setTimeout(() => { + if (!shuttingDown && !sock) startSocket().catch(() => {}) + }, delay) + } + }) + + // Initial history sync — the richest source of jid→name pairs. + sock.ev.on('messaging-history.set', ({ contacts }) => { + for (const c of contacts || []) rememberContact(c.id, c.name || c.notify || c.verifiedName) + }) + sock.ev.on('contacts.upsert', (contacts) => { + for (const c of contacts || []) rememberContact(c.id, c.name || c.notify || c.verifiedName) + }) + sock.ev.on('contacts.update', (updates) => { + for (const c of updates || []) { + if (c.id && (c.name || c.notify)) rememberContact(c.id, c.name || c.notify) + } + }) + sock.ev.on('chats.upsert', (chats) => { + for (const c of chats || []) rememberContact(c.id, c.name) + }) + + sock.ev.on('messages.upsert', ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') return + for (const msg of messages || []) { + try { + ingestMessage(msg) + } catch (err) { + logger.warn({ err: String(err) }, 'failed to ingest message') + } + } + }) + } catch (err) { + logger.error({ err: String(err) }, 'startSocket failed') + sock = null + startPromise = null + state = hasSavedSession() ? 'connecting' : 'unlinked' + throw err + } + })() + return startPromise +} + +function extractText(message) { + if (!message) return null + // Unwrap the containers WhatsApp nests real content in. + const inner = + message.ephemeralMessage?.message || + message.viewOnceMessage?.message || + message.viewOnceMessageV2?.message || + message.documentWithCaptionMessage?.message || + message + return ( + inner.conversation || + inner.extendedTextMessage?.text || + inner.imageMessage?.caption || + inner.videoMessage?.caption || + null + ) +} + +function ingestMessage(msg) { + const jid = msg.key?.remoteJid + // Direct (1:1) chats only — never groups, broadcasts, or status updates. + if (!jid || !jid.endsWith('@s.whatsapp.net')) return + const text = extractText(msg.message) + if (!text) return + const fromMe = !!msg.key?.fromMe + if (!fromMe && msg.pushName) rememberContact(jid, msg.pushName) + if (!fromMe && msg.key?.id) lastIncomingKeyByJid.set(jid, msg.key) + eventSeq += 1 + events.push({ + seq: eventSeq, + jid, + phone: phoneFromJid(jid), + fromMe, + text, + timestamp: Number(msg.messageTimestamp) || Math.floor(Date.now() / 1000), + senderName: fromMe ? null : msg.pushName || contactsByJid.get(jid)?.name || null, + }) + if (events.length > MAX_EVENTS) events.splice(0, events.length - MAX_EVENTS) +} + +function clearSession() { + try { + rmSync(SESSION_DIR, { recursive: true, force: true }) + } catch (err) { + logger.warn({ err: String(err) }, 'failed to clear session dir') + } +} + +// --------------------------------------------------------------------------- +// HTTP helpers + +function sendJson(res, status, body) { + const data = JSON.stringify(body) + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }) + res.end(data) +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let raw = '' + req.on('data', (chunk) => { + raw += chunk + if (raw.length > 1_000_000) reject(new Error('body too large')) + }) + req.on('end', () => { + if (!raw) return resolve({}) + try { + resolve(JSON.parse(raw)) + } catch { + reject(new Error('invalid JSON body')) + } + }) + req.on('error', reject) + }) +} + +function linkStatusBody() { + return { + state, + qrDataUrl: state === 'waiting_qr' ? latestQrDataUrl : null, + phone: state === 'linked' ? linkedPhone : null, + } +} + +function normalizeSendTarget(to) { + const raw = String(to || '').trim() + if (!raw) return null + if (raw.includes('@')) return raw.endsWith('@s.whatsapp.net') ? raw : null + const digits = raw.replace(/[\s()+\-.]/g, '') + if (!/^\d{5,20}$/.test(digits)) return null + return `${digits}@s.whatsapp.net` +} + +function resolveByName(name) { + const needle = String(name || '').trim().toLowerCase() + if (!needle) return null + const all = [...contactsByJid.values()] + const exact = all.filter((c) => c.name.toLowerCase() === needle) + if (exact.length === 1) return exact[0] + if (exact.length > 1) return null // ambiguous — refuse rather than guess + const partial = all.filter( + (c) => c.name.toLowerCase().startsWith(needle) || needle.startsWith(c.name.toLowerCase()) + ) + return partial.length === 1 ? partial[0] : null +} + +// --------------------------------------------------------------------------- +// HTTP server + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`) + if (TOKEN && req.headers['x-omi-token'] !== TOKEN) { + return sendJson(res, 401, { error: 'unauthorized' }) + } + + try { + if (req.method === 'GET' && url.pathname === '/health') { + return sendJson(res, 200, { ok: true, state }) + } + + if (req.method === 'GET' && url.pathname === '/link/status') { + return sendJson(res, 200, linkStatusBody()) + } + + if (req.method === 'POST' && url.pathname === '/link/start') { + if (state !== 'linked' && !sock) { + startSocket().catch((err) => logger.warn({ err: String(err) }, 'link start failed')) + // Give Baileys a moment so the first status poll usually already has the QR. + await new Promise((r) => setTimeout(r, 1500)) + } + return sendJson(res, 200, linkStatusBody()) + } + + if (req.method === 'POST' && url.pathname === '/send') { + if (state !== 'linked' || !sock) { + return sendJson(res, 409, { error: 'not linked — scan the QR code first' }) + } + const body = await readBody(req) + const jid = normalizeSendTarget(body.to) + const text = String(body.text || '').trim() + if (!jid) return sendJson(res, 400, { error: 'invalid "to" — need digits or a @s.whatsapp.net JID' }) + if (!text) return sendJson(res, 400, { error: 'empty "text"' }) + await sock.sendMessage(jid, { text }) + return sendJson(res, 200, { sent: true, jid, phone: phoneFromJid(jid) }) + } + + if (req.method === 'POST' && url.pathname === '/read') { + if (state !== 'linked' || !sock) { + return sendJson(res, 409, { error: 'not linked' }) + } + const body = await readBody(req) + const jid = normalizeSendTarget(body.to) + if (!jid) return sendJson(res, 400, { error: 'invalid "to"' }) + const key = lastIncomingKeyByJid.get(jid) + if (!key) return sendJson(res, 200, { read: false, reason: 'no tracked incoming message' }) + await sock.readMessages([key]) + return sendJson(res, 200, { read: true, jid }) + } + + if (req.method === 'POST' && url.pathname === '/presence') { + if (state !== 'linked' || !sock) { + return sendJson(res, 409, { error: 'not linked' }) + } + const body = await readBody(req) + const jid = normalizeSendTarget(body.to) + if (!jid) return sendJson(res, 400, { error: 'invalid "to"' }) + const presence = body.state === 'composing' ? 'composing' : 'paused' + await sock.sendPresenceUpdate(presence, jid) + return sendJson(res, 200, { presence, jid }) + } + + if (req.method === 'GET' && url.pathname === '/events') { + const since = Number(url.searchParams.get('since') || 0) + return sendJson(res, 200, { + events: events.filter((e) => e.seq > since), + latest: eventSeq, + state, + }) + } + + if (req.method === 'GET' && url.pathname === '/resolve') { + const match = resolveByName(url.searchParams.get('name')) + if (!match) return sendJson(res, 404, { error: 'no unambiguous contact match' }) + return sendJson(res, 200, { jid: match.jid, phone: phoneFromJid(match.jid), name: match.name }) + } + + if (req.method === 'POST' && url.pathname === '/logout') { + shuttingDownSocketOnly = true + try { + await sock?.logout() + } catch (err) { + logger.warn({ err: String(err) }, 'logout call failed (clearing session anyway)') + } + shuttingDownSocketOnly = false + sock = null + startPromise = null + clearSession() + state = 'unlinked' + linkedPhone = null + latestQrDataUrl = null + return sendJson(res, 200, linkStatusBody()) + } + + return sendJson(res, 404, { error: 'not found' }) + } catch (err) { + logger.error({ err: String(err) }, 'request failed') + return sendJson(res, 500, { error: String(err?.message || err) }) + } +}) + +// Suppresses the reconnect handler while an intentional logout tears the socket down. +let shuttingDownSocketOnly = false + +server.listen(PORT, '127.0.0.1', () => { + loadContacts() + // Announce readiness on stdout (single JSON line) so the spawning app can wait for it. + process.stdout.write(JSON.stringify({ type: 'ready', port: PORT, state }) + '\n') + // Resume automatically when a saved session exists — no QR needed after first link. + if (hasSavedSession()) { + startSocket().catch((err) => logger.warn({ err: String(err) }, 'session resume failed')) + } +}) + +server.on('error', (err) => { + process.stdout.write(JSON.stringify({ type: 'error', error: String(err?.message || err) }) + '\n') + process.exit(1) +}) + +// --------------------------------------------------------------------------- +// Parent tether — never outlive the app that spawned us. + +function shutdown(code = 0) { + if (shuttingDown) return + shuttingDown = true + try { + sock?.end?.(undefined) + } catch {} + try { + server.close() + } catch {} + // Hard exit shortly after — Baileys keeps sockets/timers alive otherwise. + setTimeout(() => process.exit(code), 250).unref() +} + +process.stdin.resume() +process.stdin.on('end', () => shutdown(0)) +process.stdin.on('close', () => shutdown(0)) +process.stdin.on('error', () => shutdown(0)) + +// Belt-and-suspenders for the stdin tether: if the spawning app passed its PID, poll it and +// exit when it disappears (covers pipe semantics edge cases so we never orphan). +const parentPid = Number(process.env.OMI_WA_PARENT_PID || 0) +if (parentPid > 0) { + setInterval(() => { + try { + process.kill(parentPid, 0) + } catch { + shutdown(0) + } + }, 5000).unref() + // unref'd so this timer never keeps the process alive on its own; shutdown() handles exit. +} +process.on('SIGTERM', () => shutdown(0)) +process.on('SIGINT', () => shutdown(0)) +process.on('uncaughtException', (err) => { + logger.error({ err: String(err?.stack || err) }, 'uncaught exception') +}) +process.on('unhandledRejection', (err) => { + logger.error({ err: String(err) }, 'unhandled rejection') +})