Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0594fab
Wire real iMessage contacts into AI Clone page
UtkarshTewari24 Jul 1, 2026
e300f3a
AI Clone: platform-agnostic message/contact abstraction
UtkarshTewari24 Jul 1, 2026
9df775e
AI Clone: persona training, preview chat, and LLM-judge backtest loop
UtkarshTewari24 Jul 1, 2026
09393ac
AI Clone: headless backtest harness, chat.db snapshot override, attac…
UtkarshTewari24 Jul 2, 2026
a49bc6c
AI Clone: retrieval + stylometry + multi-candidate architecture, deli…
UtkarshTewari24 Jul 2, 2026
815649b
AI Clone: keep backtest eval pairs out of persona few-shot examples
UtkarshTewari24 Jul 2, 2026
a095da0
AI Clone: ai_clone_rejudge harness action for judge-variance measurement
UtkarshTewari24 Jul 2, 2026
8661049
AI Clone: extract preview bubble splitting into testable helper
UtkarshTewari24 Jul 2, 2026
b3d52fa
AI Clone: gate raw model-output logging to non-production builds
UtkarshTewari24 Jul 2, 2026
91d909c
AI Clone: import Telegram + WhatsApp chat history for training
UtkarshTewari24 Jul 2, 2026
02684db
AI Clone: unified send-mode system (manual / draft-review / autonomous)
UtkarshTewari24 Jul 3, 2026
867ee7e
AI Clone: send-mode test harness + kill-switch unit tests
UtkarshTewari24 Jul 3, 2026
3fa9d1b
AI Clone: force neutral tint on send-mode Menu (no accent/purple)
UtkarshTewari24 Jul 3, 2026
6b68a55
AI Clone: WhatsApp sidecar — local Baileys HTTP service (link/send/ev…
UtkarshTewari24 Jul 3, 2026
b9b645f
AI Clone: WhatsAppSendService actor + send-mode routing via Baileys s…
UtkarshTewari24 Jul 3, 2026
52c61f4
AI Clone: WhatsApp QR-linking sheet + autonomous risk gate UI + tests
UtkarshTewari24 Jul 3, 2026
547d36e
AI Clone: changelog fragment for WhatsApp send/linking
UtkarshTewari24 Jul 3, 2026
50d075c
AI Clone: read-only Memories + Calendar context for respond()
UtkarshTewari24 Jul 3, 2026
b10b5f7
AI Clone: never let a keychain prompt hang reply generation
UtkarshTewari24 Jul 3, 2026
62c425b
AI Clone: live-conversation chat sheet (real thread + suggested replies)
UtkarshTewari24 Jul 4, 2026
db4c7ea
AI Clone: send multi-bubble replies as separate burst messages
UtkarshTewari24 Jul 4, 2026
38103ca
AI Clone: app-level listeners, shared LLM bridge, human-paced autonom…
UtkarshTewari24 Jul 4, 2026
9a6d580
AI Clone: Commitment Tracking — scan message history for promises → T…
UtkarshTewari24 Jul 5, 2026
0a0a12e
AI Clone: commitment scan also catches deliverables a contact is chasing
UtkarshTewari24 Jul 5, 2026
0810911
AI Clone: resolve iMessage handles to real Contacts names everywhere
UtkarshTewari24 Jul 5, 2026
286ace8
AI Clone: remove the per-contact "Scan for Commitments" button
UtkarshTewari24 Jul 5, 2026
4e3c731
AgentBridge: skip the client-side usage-quota gate on non-production …
UtkarshTewari24 Jul 5, 2026
03313a6
Add explicit Codex agent routing
UtkarshTewari24 Jul 5, 2026
ffaab12
Add agent auto-selection fallback
UtkarshTewari24 Jul 5, 2026
1816335
Add agent install help flow
UtkarshTewari24 Jul 5, 2026
73779d2
Refine connected agent auto-selection
UtkarshTewari24 Jul 6, 2026
68300a5
Use LLM agent mention detection
UtkarshTewari24 Jul 6, 2026
7b48013
Discover freshly installed agents at query time
UtkarshTewari24 Jul 6, 2026
f47052e
Strip top-level schema combinators from stdio MCP tools
UtkarshTewari24 Jul 6, 2026
f1988fc
Add Hermes device-code sign-in flow (Settings + chat prompt)
UtkarshTewari24 Jul 6, 2026
fcc72cc
Make the Hermes agent work without paid Nous credits
UtkarshTewari24 Jul 6, 2026
c2eb11f
Auto-onboard OpenClaw so a fresh install actually works
UtkarshTewari24 Jul 7, 2026
c6585ca
Fix the What-omi-knows dashboard grid and strip dead home views
UtkarshTewari24 Jul 7, 2026
e92b17d
Polish AI Clone button copy for consistency
UtkarshTewari24 Jul 7, 2026
ba82820
Fall back to bring-your-own-key OpenClaw setup when Claude Code is mi…
UtkarshTewari24 Jul 7, 2026
07c7b34
AI Clone: conversation continuity, topic control, tasks from messages…
UtkarshTewari24 Jul 7, 2026
8021f94
Send clone replies the moment they're generated
UtkarshTewari24 Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions desktop/macos/Desktop/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ver>-<sha> 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(
Expand Down Expand Up @@ -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: [
Expand Down
505 changes: 505 additions & 0 deletions desktop/macos/Desktop/Sources/AICloneBacktestService.swift

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions desktop/macos/Desktop/Sources/AICloneChatHarness.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
271 changes: 271 additions & 0 deletions desktop/macos/Desktop/Sources/AICloneContextService.swift
Original file line number Diff line number Diff line change
@@ -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<T: Sendable>(
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..<lowered.endIndex
while let range = lowered.range(of: phrase, range: searchRange) {
let before = lowered[lowered.index(before: range.lowerBound)]
let afterIndex = range.upperBound
let after = afterIndex < lowered.endIndex ? lowered[afterIndex] : " "
if !before.isLetter, !after.isLetter { return true }
searchRange = range.upperBound..<lowered.endIndex
}
}
// Clock times: "3pm", "3 pm", "10:30", "10.30am"
let timePattern = #"\b\d{1,2}([:.]\d{2})?\s*(am|pm)\b|\b\d{1,2}:\d{2}\b"#
return lowered.range(of: timePattern, options: .regularExpression) != nil
}

// MARK: - Fixtures (non-production test harnesses only)

/// JSON `["fact", …]` file override, mirroring the `aiCloneChatDbPathOverride` pattern
/// so headless lab bundles can test deterministically. Never active in production.
private static func loadFixtureStrings(key: String) -> [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
}
}
Loading