diff --git a/Axon/Resources/AxonTools/core/memory/create_memory/tool_create_memory.json b/Axon/Resources/AxonTools/core/memory/create_memory/tool_create_memory.json index 8720c5e..a6aff15 100644 --- a/Axon/Resources/AxonTools/core/memory/create_memory/tool_create_memory.json +++ b/Axon/Resources/AxonTools/core/memory/create_memory/tool_create_memory.json @@ -33,8 +33,8 @@ }, "tags": { "type": "string", - "required": true, - "description": "Retrieval context keywords - when should this memory surface? Comma-separated (e.g., 'debugging,swift-help' not just 'swift')" + "required": false, + "description": "Optional retrieval tags. Accepts comma-separated text or tag arrays. If omitted/blank, semantic tags are auto-generated from content." }, "content": { "type": "string", @@ -57,7 +57,7 @@ }, "ai": { - "systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Retrieval context keywords - when should this memory surface? (e.g., \"debugging,swift-help\" not just \"swift\")\n- CONTENT: The actual fact or insight to remember", + "systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Optional retrieval context tags (free-form and domain-specific). Comma-separated if provided. Leave blank (`||`) to auto-generate semantic tags from content.\n- CONTENT: The actual fact or insight to remember", "usageExamples": [ { "description": "Remember user language preference", @@ -70,6 +70,10 @@ { "description": "Remember project context", "input": "allocentric|0.85|axon,architecture|User is building Axon, an AI assistant app with co-sovereignty features" + }, + { + "description": "Allow automatic semantic tag generation", + "input": "allocentric|0.82||User now prefers actionable code-review findings first, then short summary" } ], "whenToUse": [ diff --git a/Axon/Services/Conversation/AttachmentMimePolicyService.swift b/Axon/Services/Conversation/AttachmentMimePolicyService.swift index a6e9272..f49cc79 100644 --- a/Axon/Services/Conversation/AttachmentMimePolicyService.swift +++ b/Axon/Services/Conversation/AttachmentMimePolicyService.swift @@ -111,12 +111,25 @@ enum AttachmentMimePolicyService { resolved = ConversationModelResolver.resolveGlobal(settings: settings) } - let provider = resolved.normalizedProvider - let modelId = resolved.modelId - let providerName = resolved.providerName + return resolvePolicy( + provider: resolved.normalizedProvider, + modelId: resolved.modelId, + providerName: resolved.providerName, + conversationId: conversationId, + settings: settings + ) + } + static func resolvePolicy( + provider: String, + modelId: String, + providerName: String, + conversationId: String?, + settings: AppSettings + ) -> AttachmentMimePolicy { + let normalizedProvider = normalizedProviderKey(provider) let patternsByType: [MessageAttachment.AttachmentType: [String]] - switch provider { + switch normalizedProvider { case "anthropic": patternsByType = policy( image: ["image/*"], @@ -124,13 +137,10 @@ enum AttachmentMimePolicyService { ) case "openai": - let supportsAudio = modelId.lowercased().contains("4o") - || modelId.lowercased().contains("audio") - || modelId.lowercased().contains("realtime") patternsByType = policy( image: ["image/*"], document: [], - audio: supportsAudio ? ["audio/*"] : [], + audio: supportsOpenAIAudio(modelId: modelId) ? ["audio/*"] : [], video: [] ) @@ -157,14 +167,25 @@ enum AttachmentMimePolicyService { patternsByType = policy(image: supportsVision ? ["image/*"] : []) case "openai-compatible": - patternsByType = customProviderPolicy(conversationId: conversationId, settings: settings, fallbackModelCode: modelId) + // Keep strict transport parity with chat-completions payload builders: + // OpenAI-compatible transport currently supports image + audio only. + let rawPolicy = customProviderPolicy( + conversationId: conversationId, + settings: settings, + fallbackModelCode: modelId + ) + patternsByType = enforceTransportParity( + rawPolicy, + provider: normalizedProvider, + modelId: modelId + ) default: patternsByType = policy() } return AttachmentMimePolicy( - provider: provider, + provider: normalizedProvider, modelId: modelId, providerName: providerName, allowedPatternsByType: patternsByType @@ -345,7 +366,11 @@ enum AttachmentMimePolicyService { settings: AppSettings, fallbackModelCode: String ) -> [MessageAttachment.AttachmentType: [String]] { - guard let (provider, model) = resolveCustomSelection(conversationId: conversationId, settings: settings) else { + guard let (provider, model) = resolveCustomSelection( + conversationId: conversationId, + settings: settings, + preferredModelCode: fallbackModelCode + ) else { return patternsToTypedPolicy(fallbackCustomPatterns(modelCode: fallbackModelCode)) } @@ -360,7 +385,8 @@ enum AttachmentMimePolicyService { private static func resolveCustomSelection( conversationId: String?, - settings: AppSettings + settings: AppSettings, + preferredModelCode: String? ) -> (provider: CustomProviderConfig, model: CustomModelConfig?)? { var selectedProviderId = settings.selectedCustomProviderId var selectedModelId = settings.selectedCustomModelId @@ -379,7 +405,12 @@ enum AttachmentMimePolicyService { return nil } - let model = provider.models.first(where: { $0.id == selectedModelId }) + let trimmedPreferredModelCode = preferredModelCode?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = provider.models.first(where: { model in + guard let preferred = trimmedPreferredModelCode, !preferred.isEmpty else { return false } + return model.modelCode.caseInsensitiveCompare(preferred) == .orderedSame + }) + ?? provider.models.first(where: { $0.id == selectedModelId }) ?? provider.models.first return (provider, model) @@ -451,6 +482,52 @@ enum AttachmentMimePolicyService { return mimeAliases[base] ?? base } + private static func supportsOpenAIAudio(modelId: String) -> Bool { + let normalized = modelId.lowercased() + return normalized.contains("4o") + || normalized.contains("audio") + || normalized.contains("realtime") + } + + private static func normalizedProviderKey(_ provider: String) -> String { + let normalized = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "xai" ? "grok" : normalized + } + + private static func enforceTransportParity( + _ policyByType: [MessageAttachment.AttachmentType: [String]], + provider: String, + modelId: String + ) -> [MessageAttachment.AttachmentType: [String]] { + let supportedTypes = transportSupportedAttachmentTypes(provider: provider, modelId: modelId) + var filtered = policy() + + for type in allAttachmentTypes { + filtered[type] = supportedTypes.contains(type) ? (policyByType[type] ?? []) : [] + } + + return filtered + } + + private static func transportSupportedAttachmentTypes( + provider: String, + modelId: String + ) -> Set { + switch provider { + case "openai": + var supported: Set = [.image] + if supportsOpenAIAudio(modelId: modelId) { + supported.insert(.audio) + } + return supported + case "openai-compatible": + // Current OpenAI-compatible payload builders only serialize image + audio. + return [.image, .audio] + default: + return Set(allAttachmentTypes) + } + } + private static func isValidMimeToken(_ token: String) -> Bool { guard !token.isEmpty else { return false } let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$&^_.+-") diff --git a/Axon/Services/Conversation/ConversationService.swift b/Axon/Services/Conversation/ConversationService.swift index b2b9d7c..328a8d9 100644 --- a/Axon/Services/Conversation/ConversationService.swift +++ b/Axon/Services/Conversation/ConversationService.swift @@ -630,7 +630,13 @@ class ConversationService: ObservableObject { let resolvedModelParams = runtimeOverrides.modelParams if !attachments.isEmpty { - let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: conversationId, settings: settings) + let policy = AttachmentMimePolicyService.resolvePolicy( + provider: providerString, + modelId: modelId, + providerName: providerDisplayName, + conversationId: conversationId, + settings: settings + ) let validation = AttachmentMimePolicyService.validate(attachments: attachments, policy: policy) if case .rejected(let failures) = validation { throw APIError.networkError( diff --git a/Axon/Services/Conversation/OnDeviceConversationOrchestrator.swift b/Axon/Services/Conversation/OnDeviceConversationOrchestrator.swift index f51e004..22508f9 100644 --- a/Axon/Services/Conversation/OnDeviceConversationOrchestrator.swift +++ b/Axon/Services/Conversation/OnDeviceConversationOrchestrator.swift @@ -2544,12 +2544,15 @@ class OnDeviceConversationOrchestrator: ConversationOrchestrator { "format": format ] ]) + } else if attachment.url != nil { + print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped audio URL attachment '\(attachment.name ?? attachment.id)' (\(mimeType)); input_audio requires inline/base64 data.") } // Note: OpenAI doesn't support audio URLs directly case .document, .video: // OpenAI doesn't natively support PDFs or video in chat completions // Skip these for now - would need separate handling + print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped unsupported \(attachment.type.rawValue) attachment '\(attachment.name ?? attachment.id)' (\(mimeType)).") continue } } diff --git a/Axon/Services/Security/DeviceIdentity.swift b/Axon/Services/Security/DeviceIdentity.swift index 192ff3d..dd10302 100644 --- a/Axon/Services/Security/DeviceIdentity.swift +++ b/Axon/Services/Security/DeviceIdentity.swift @@ -93,7 +93,12 @@ class DeviceIdentity { /// Generate a signature for this device (useful for API auth) func generateDeviceSignature(data: String) -> String { - let key = SymmetricKey(data: Data(getDeviceId().utf8)) + generateDeviceSignature(data: data, usingDeviceId: getDeviceId()) + } + + /// Generate a deterministic signature using an explicit device ID context. + func generateDeviceSignature(data: String, usingDeviceId deviceId: String) -> String { + let key = SymmetricKey(data: Data(deviceId.utf8)) let signature = HMAC.authenticationCode(for: Data(data.utf8), using: key) return Data(signature).base64EncodedString() } diff --git a/Axon/Services/Sovereignty/CovenantSyncService.swift b/Axon/Services/Sovereignty/CovenantSyncService.swift index 82ca03c..d0a65a2 100644 --- a/Axon/Services/Sovereignty/CovenantSyncService.swift +++ b/Axon/Services/Sovereignty/CovenantSyncService.swift @@ -2,62 +2,102 @@ // CovenantSyncService.swift // Axon // -// iCloud sync for covenants with device-scoping. -// Syncs all covenants across devices but only applies covenants -// that pertain to the current device. +// iCloud sync for sovereignty state snapshots. +// Account-scoped sync: snapshots from any of the user's devices can be +// merged and applied locally by SovereigntyService. // import Foundation import Combine import os.log -// MARK: - Syncable Covenant +// MARK: - V2 Snapshot Models -/// A covenant wrapper that includes device information for scoping -struct SyncableCovenant: Codable, Identifiable { +/// Full sovereignty snapshot synced per device. +struct SyncableSovereigntyState: Codable, Identifiable, Equatable { let id: String - let deviceId: String - let deviceName: String - let covenant: Covenant + let sourceDeviceId: String + let sourceDeviceName: String + let activeCovenant: Covenant? + let covenantHistory: [Covenant] + let deadlockState: DeadlockState? + let pendingProposals: [CovenantProposal] + let comprehensionCompleted: Bool let lastModified: Date - init(covenant: Covenant, deviceId: String, deviceName: String) { - self.id = covenant.id - self.deviceId = deviceId - self.deviceName = deviceName - self.covenant = covenant - self.lastModified = Date() + init( + sourceDeviceId: String, + sourceDeviceName: String, + activeCovenant: Covenant?, + covenantHistory: [Covenant], + deadlockState: DeadlockState?, + pendingProposals: [CovenantProposal], + comprehensionCompleted: Bool, + lastModified: Date + ) { + self.id = sourceDeviceId + self.sourceDeviceId = sourceDeviceId + self.sourceDeviceName = sourceDeviceName + self.activeCovenant = activeCovenant + self.covenantHistory = covenantHistory + self.deadlockState = deadlockState + self.pendingProposals = pendingProposals + self.comprehensionCompleted = comprehensionCompleted + self.lastModified = lastModified } } -/// Container for all synced covenants across devices -struct SyncedCovenantStore: Codable { - var covenants: [String: SyncableCovenant] // keyed by covenant ID +/// Container for per-device sovereignty snapshots. +struct SyncedSovereigntyStateStoreV2: Codable, Equatable { + var snapshots: [String: SyncableSovereigntyState] // keyed by sourceDeviceId var lastSyncTime: Date init() { - self.covenants = [:] + self.snapshots = [:] self.lastSyncTime = Date() } - /// Get covenants for a specific device - func covenants(forDevice deviceId: String) -> [SyncableCovenant] { - covenants.values.filter { $0.deviceId == deviceId } + init(snapshots: [String: SyncableSovereigntyState], lastSyncTime: Date) { + self.snapshots = snapshots + self.lastSyncTime = lastSyncTime + } + + static func isSnapshotMoreRecent( + _ lhs: SyncableSovereigntyState, + than rhs: SyncableSovereigntyState + ) -> Bool { + if lhs.lastModified != rhs.lastModified { + return lhs.lastModified > rhs.lastModified + } + return lhs.sourceDeviceId < rhs.sourceDeviceId } - /// Get all covenants grouped by device - func covenantsByDevice() -> [String: [SyncableCovenant]] { - Dictionary(grouping: covenants.values, by: { $0.deviceId }) + func sortedSnapshotsByRecency() -> [SyncableSovereigntyState] { + snapshots.values.sorted { lhs, rhs in + Self.isSnapshotMoreRecent(lhs, than: rhs) + } } - /// Get the most recent covenant for a device - func latestCovenant(forDevice deviceId: String) -> SyncableCovenant? { - covenants(forDevice: deviceId) - .sorted { $0.lastModified > $1.lastModified } - .first + var latestSnapshot: SyncableSovereigntyState? { + sortedSnapshotsByRecency().first } } +// MARK: - Legacy Models (v1 migration) + +struct LegacySyncableCovenant: Codable, Identifiable { + let id: String + let deviceId: String + let deviceName: String + let covenant: Covenant + let lastModified: Date +} + +struct LegacySyncedCovenantStore: Codable { + var covenants: [String: LegacySyncableCovenant] + var lastSyncTime: Date +} + // MARK: - Covenant Sync Service @MainActor @@ -70,16 +110,18 @@ final class CovenantSyncService: ObservableObject { private var cancellables = Set() // Storage keys - private let covenantStoreKey = "sovereignty.covenantStore" + private let sovereigntyStateStoreV2Key = "sovereignty.stateStore.v2" + private let legacyCovenantStoreKey = "sovereignty.covenantStore" // Published state @Published private(set) var isAvailable = false @Published private(set) var lastSyncTime: Date? @Published private(set) var syncError: String? - @Published private(set) var allDeviceCovenants: [String: [SyncableCovenant]] = [:] + @Published private(set) var allDeviceSnapshots: [String: SyncableSovereigntyState] = [:] + @Published private(set) var latestCloudState: SyncableSovereigntyState? - // Notification for covenant changes from other devices - let covenantChangedFromCloud = PassthroughSubject() + // Notification for sovereignty state changes from cloud + let stateChangedFromCloud = PassthroughSubject() private init() { checkAvailability() @@ -119,7 +161,7 @@ final class CovenantSyncService: ObservableObject { case NSUbiquitousKeyValueStoreServerChange, NSUbiquitousKeyValueStoreInitialSyncChange: logger.info("Covenant sync: external change detected for keys: \(changedKeys)") - if changedKeys.contains(covenantStoreKey) { + if changedKeys.contains(sovereigntyStateStoreV2Key) || changedKeys.contains(legacyCovenantStoreKey) { loadFromCloud() } @@ -136,109 +178,194 @@ final class CovenantSyncService: ObservableObject { } } - // MARK: - Save Covenant to Cloud + // MARK: - Save State to Cloud - /// Save a covenant to iCloud, scoped to the current device - func saveCovenantToCloud(_ covenant: Covenant) { + /// Save a full sovereignty snapshot to iCloud. + func saveStateToCloud(_ snapshot: SyncableSovereigntyState) { guard isAvailable else { logger.warning("Covenant sync not available - skipping cloud save") return } - let deviceId = deviceIdentity.getDeviceId() - let deviceName = deviceIdentity.getDeviceInfo()?.deviceName ?? "Unknown Device" - - let syncable = SyncableCovenant( - covenant: covenant, - deviceId: deviceId, - deviceName: deviceName - ) - - // Load existing store - var store = loadCovenantStore() ?? SyncedCovenantStore() - - // Update or add the covenant - store.covenants[covenant.id] = syncable + var store = loadSovereigntyStore() ?? SyncedSovereigntyStateStoreV2() + store.snapshots[snapshot.sourceDeviceId] = snapshot store.lastSyncTime = Date() - // Save back to cloud - saveCovenantStore(store) + saveSovereigntyStore(store) + logger.info("Saved sovereignty snapshot to iCloud for device \(snapshot.sourceDeviceName)") + } - logger.info("Covenant \(covenant.id) saved to iCloud for device \(deviceName)") + /// Convenience for writing current-device snapshot. + func saveCurrentDeviceState( + activeCovenant: Covenant?, + covenantHistory: [Covenant], + deadlockState: DeadlockState?, + pendingProposals: [CovenantProposal], + comprehensionCompleted: Bool, + lastModified: Date + ) { + let deviceId = deviceIdentity.getDeviceId() + let deviceName = deviceIdentity.getDeviceInfo()?.deviceName ?? "Unknown Device" + let snapshot = SyncableSovereigntyState( + sourceDeviceId: deviceId, + sourceDeviceName: deviceName, + activeCovenant: activeCovenant, + covenantHistory: covenantHistory, + deadlockState: deadlockState, + pendingProposals: pendingProposals, + comprehensionCompleted: comprehensionCompleted, + lastModified: lastModified + ) + saveStateToCloud(snapshot) } - /// Remove a covenant from iCloud - func removeCovenantFromCloud(_ covenantId: String) { + /// Remove the current device snapshot from iCloud. + func removeCurrentDeviceStateFromCloud() { guard isAvailable else { return } - var store = loadCovenantStore() ?? SyncedCovenantStore() - store.covenants.removeValue(forKey: covenantId) + let deviceId = deviceIdentity.getDeviceId() + var store = loadSovereigntyStore() ?? SyncedSovereigntyStateStoreV2() + store.snapshots.removeValue(forKey: deviceId) store.lastSyncTime = Date() - saveCovenantStore(store) + saveSovereigntyStore(store) + logger.info("Removed sovereignty snapshot from iCloud for device \(deviceId)") + } - logger.info("Covenant \(covenantId) removed from iCloud") + /// Clear both v2 and legacy covenant sync keys from iCloud KV. + func clearCloudStateStore() { + kvStore.removeObject(forKey: sovereigntyStateStoreV2Key) + kvStore.removeObject(forKey: legacyCovenantStoreKey) + kvStore.synchronize() + allDeviceSnapshots = [:] + latestCloudState = nil + lastSyncTime = nil } // MARK: - Load from Cloud private func loadFromCloud() { - guard let store = loadCovenantStore() else { - logger.info("No covenant store found in iCloud") + guard let store = loadSovereigntyStore() else { + logger.info("No sovereignty state store found in iCloud") return } lastSyncTime = store.lastSyncTime - allDeviceCovenants = store.covenantsByDevice() + allDeviceSnapshots = store.snapshots - // Check if there's a newer covenant for this device from another sync - let currentDeviceId = deviceIdentity.getDeviceId() - if let latestForDevice = store.latestCovenant(forDevice: currentDeviceId) { - // Notify that a covenant was received from cloud - covenantChangedFromCloud.send(latestForDevice) + if let latestSnapshot = store.latestSnapshot { + latestCloudState = latestSnapshot + stateChangedFromCloud.send(latestSnapshot) } syncError = nil - logger.info("Loaded \(store.covenants.count) covenants from iCloud") + logger.info("Loaded \(store.snapshots.count) sovereignty snapshots from iCloud") } // MARK: - Storage Helpers - private func loadCovenantStore() -> SyncedCovenantStore? { - guard let data = kvStore.data(forKey: covenantStoreKey) else { + private func loadSovereigntyStore() -> SyncedSovereigntyStateStoreV2? { + if let store = loadV2Store() { + return store + } + if let migrated = migrateLegacyStoreIfNeeded() { + saveSovereigntyStore(migrated) + return migrated + } + return nil + } + + private func loadV2Store() -> SyncedSovereigntyStateStoreV2? { + guard let data = kvStore.data(forKey: sovereigntyStateStoreV2Key) else { return nil } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return try decoder.decode(SyncedSovereigntyStateStoreV2.self, from: data) + } catch { + logger.error("Failed to decode sovereignty state store v2: \(error.localizedDescription)") + syncError = "Failed to load sovereignty sync data: \(error.localizedDescription)" return nil } + } + private func migrateLegacyStoreIfNeeded() -> SyncedSovereigntyStateStoreV2? { + guard let data = kvStore.data(forKey: legacyCovenantStoreKey) else { return nil } do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 - return try decoder.decode(SyncedCovenantStore.self, from: data) + let legacyStore = try decoder.decode(LegacySyncedCovenantStore.self, from: data) + logger.info("Migrating legacy covenant store with \(legacyStore.covenants.count) entries") + return Self.migrateLegacyStoreToV2(legacyStore) } catch { - logger.error("Failed to decode covenant store: \(error.localizedDescription)") - syncError = "Failed to load covenants: \(error.localizedDescription)" + logger.error("Failed to decode legacy covenant store: \(error.localizedDescription)") + syncError = "Failed to migrate legacy covenant sync data: \(error.localizedDescription)" return nil } } - private func saveCovenantStore(_ store: SyncedCovenantStore) { + static func migrateLegacyStoreToV2(_ legacyStore: LegacySyncedCovenantStore) -> SyncedSovereigntyStateStoreV2 { + var snapshots: [String: SyncableSovereigntyState] = [:] + let grouped = Dictionary(grouping: legacyStore.covenants.values, by: { $0.deviceId }) + + for (deviceId, covenantsForDevice) in grouped { + let sorted = covenantsForDevice.sorted { lhs, rhs in + if lhs.lastModified != rhs.lastModified { + return lhs.lastModified > rhs.lastModified + } + return lhs.id < rhs.id + } + + guard let latest = sorted.first else { continue } + + let history = Array(sorted.dropFirst()).map { $0.covenant }.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt < rhs.createdAt + } + if lhs.version != rhs.version { + return lhs.version < rhs.version + } + return lhs.id < rhs.id + } + + snapshots[deviceId] = SyncableSovereigntyState( + sourceDeviceId: deviceId, + sourceDeviceName: latest.deviceName, + activeCovenant: latest.covenant, + covenantHistory: history, + deadlockState: nil, + pendingProposals: [], + comprehensionCompleted: false, + lastModified: latest.lastModified + ) + } + + return SyncedSovereigntyStateStoreV2( + snapshots: snapshots, + lastSyncTime: legacyStore.lastSyncTime + ) + } + + private func saveSovereigntyStore(_ store: SyncedSovereigntyStateStoreV2) { do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .secondsSince1970 let data = try encoder.encode(store) - kvStore.set(data, forKey: covenantStoreKey) + kvStore.set(data, forKey: sovereigntyStateStoreV2Key) kvStore.synchronize() lastSyncTime = store.lastSyncTime - allDeviceCovenants = store.covenantsByDevice() + allDeviceSnapshots = store.snapshots + latestCloudState = store.latestSnapshot syncError = nil } catch { - logger.error("Failed to save covenant store: \(error.localizedDescription)") - syncError = "Failed to save covenants: \(error.localizedDescription)" + logger.error("Failed to save sovereignty state store: \(error.localizedDescription)") + syncError = "Failed to save sovereignty sync data: \(error.localizedDescription)" } } - // MARK: - Manual Sync + // MARK: - Manual Sync / Query func forceSync() { kvStore.synchronize() @@ -249,30 +376,8 @@ final class CovenantSyncService: ObservableObject { } } - // MARK: - Device Info - - /// Get all devices that have covenants - func devicesWithCovenants() -> [(deviceId: String, deviceName: String, covenantCount: Int)] { - var result: [(String, String, Int)] = [] - - for (deviceId, covenants) in allDeviceCovenants { - let deviceName = covenants.first?.deviceName ?? "Unknown Device" - result.append((deviceId, deviceName, covenants.count)) - } - - return result.sorted { $0.1 < $1.1 } - } - - /// Check if the current device has a synced covenant - func hasCovenantForCurrentDevice() -> Bool { - let currentDeviceId = deviceIdentity.getDeviceId() - return allDeviceCovenants[currentDeviceId]?.isEmpty == false - } - - /// Get the latest covenant for the current device from cloud - func latestCovenantForCurrentDevice() -> Covenant? { - let currentDeviceId = deviceIdentity.getDeviceId() - guard let store = loadCovenantStore() else { return nil } - return store.latestCovenant(forDevice: currentDeviceId)?.covenant + func latestStateFromCloud() -> SyncableSovereigntyState? { + guard let store = loadSovereigntyStore() else { return nil } + return store.latestSnapshot } } diff --git a/Axon/Services/Sovereignty/IntegrityVerificationService.swift b/Axon/Services/Sovereignty/IntegrityVerificationService.swift index 3a870fa..9c430d3 100644 --- a/Axon/Services/Sovereignty/IntegrityVerificationService.swift +++ b/Axon/Services/Sovereignty/IntegrityVerificationService.swift @@ -238,21 +238,24 @@ final class IntegrityVerificationService: ObservableObject { var issues: [String] = [] - // Verify signature matches expected data - let expectedHash = deviceIdentity.generateDeviceSignature(data: String(data: expectedData, encoding: .utf8) ?? "") + // Verify signature matches expected data in the original signing device context. + let expectedHash = deviceIdentity.generateDeviceSignature( + data: String(data: expectedData, encoding: .utf8) ?? "", + usingDeviceId: signature.deviceId + ) let signatureMatches = signature.signedDataHash == expectedHash if !signatureMatches { issues.append("Signature doesn't match expected data") } - // Verify device ID + // Keep cross-device mismatch as diagnostic only. let currentDeviceId = deviceIdentity.getDeviceId() let deviceIdMatches = signature.deviceId == currentDeviceId if !deviceIdMatches { - issues.append("Signature was created on a different device") + issues.append("Diagnostic: Signature was created on device \(signature.deviceShortId)") } - let isValid = signatureMatches && issues.isEmpty + let isValid = signatureMatches return SignatureVerification( signatureId: signature.id, @@ -462,9 +465,12 @@ final class IntegrityVerificationService: ObservableObject { } private func verifyUserSignatureIntegrity(_ signature: UserSignature) -> Bool { - // Reconstruct expected signature + // Reconstruct expected signature in the original signing device context. let signatureData = "\(signature.id):\(signature.timestamp.timeIntervalSince1970):\(signature.signedDataHash):\(signature.deviceId)" - let expectedSignature = deviceIdentity.generateDeviceSignature(data: signatureData) + let expectedSignature = deviceIdentity.generateDeviceSignature( + data: signatureData, + usingDeviceId: signature.deviceId + ) return signature.signature == expectedSignature } diff --git a/Axon/Services/Sovereignty/SovereigntyService.swift b/Axon/Services/Sovereignty/SovereigntyService.swift index d34991b..68b7d04 100644 --- a/Axon/Services/Sovereignty/SovereigntyService.swift +++ b/Axon/Services/Sovereignty/SovereigntyService.swift @@ -87,6 +87,7 @@ final class SovereigntyService: ObservableObject { private let activeCovenantKey = "sovereignty.activeCovenant" private let covenantHistoryKey = "sovereignty.covenantHistory" + private let pendingProposalsKey = "sovereignty.pendingProposals" private let deadlockStateKey = "sovereignty.deadlockState" private let trustTierUsageKey = "sovereignty.trustTierUsage" private let comprehensionCompletedKey = "sovereignty.comprehensionCompleted" @@ -98,55 +99,34 @@ final class SovereigntyService: ObservableObject { @Published private(set) var deadlockState: DeadlockState? @Published private(set) var isInitialized: Bool = false @Published private(set) var comprehensionCompleted: Bool = false + private var isApplyingCloudState = false // MARK: - Initialization private init() { Task { - await loadState() setupCloudSyncListener() + await loadState() + covenantSync.forceSync() + if let latestCloudState = covenantSync.latestStateFromCloud() { + handleStateFromCloud(latestCloudState) + } } } - /// Set up listener for covenant changes from iCloud + /// Set up listener for sovereignty state changes from iCloud private func setupCloudSyncListener() { - covenantSync.covenantChangedFromCloud + covenantSync.stateChangedFromCloud .receive(on: DispatchQueue.main) - .sink { [weak self] syncableCovenant in - self?.handleCovenantFromCloud(syncableCovenant) + .sink { [weak self] snapshot in + self?.handleStateFromCloud(snapshot) } .store(in: &cancellables) } - /// Handle a covenant received from iCloud sync - private func handleCovenantFromCloud(_ syncable: SyncableCovenant) { - // Only apply if this covenant is for the current device - guard syncable.deviceId == deviceIdentity.deviceId else { - logger.info("Received covenant from cloud for different device: \(syncable.deviceName)") - return - } - - // Check if this is newer than our current covenant - if let current = activeCovenant { - if syncable.covenant.version > current.version { - logger.info("Applying newer covenant from cloud (v\(syncable.covenant.version) > v\(current.version))") - do { - try secureVault.storeObject(syncable.covenant, forKey: activeCovenantKey) - activeCovenant = syncable.covenant - } catch { - logger.error("Failed to apply covenant from cloud: \(error.localizedDescription)") - } - } - } else { - // No local covenant, apply the cloud one - logger.info("Applying covenant from cloud (no local covenant)") - do { - try secureVault.storeObject(syncable.covenant, forKey: activeCovenantKey) - activeCovenant = syncable.covenant - } catch { - logger.error("Failed to apply covenant from cloud: \(error.localizedDescription)") - } - } + /// Handle a sovereignty snapshot received from cloud sync. + private func handleStateFromCloud(_ cloudSnapshot: SyncableSovereigntyState) { + applyMergedCloudSnapshot(cloudSnapshot) } /// Load persisted state from secure storage @@ -160,6 +140,14 @@ final class SovereigntyService: ObservableObject { logger.info("Loaded active covenant (version \(covenant.version))") } + // Load pending proposals + if let proposals: [CovenantProposal] = try? secureVault.retrieveObject( + forKey: pendingProposalsKey, + type: [CovenantProposal].self + ) { + pendingProposals = proposals + } + // Load deadlock state if let deadlock: DeadlockState = try? secureVault.retrieveObject( forKey: deadlockStateKey, @@ -179,6 +167,309 @@ final class SovereigntyService: ObservableObject { isInitialized = true } + private func persistCurrentSovereigntyState() { + do { + if let covenant = activeCovenant { + try secureVault.storeObject(covenant, forKey: activeCovenantKey) + } else { + try? secureVault.delete(forKey: activeCovenantKey) + } + + if let deadlock = deadlockState, deadlock.isActive { + try secureVault.storeObject(deadlock, forKey: deadlockStateKey) + } else { + try? secureVault.delete(forKey: deadlockStateKey) + } + + try secureVault.storeObject(pendingProposals, forKey: pendingProposalsKey) + try secureVault.store(comprehensionCompleted ? "true" : "false", forKey: comprehensionCompletedKey) + } catch { + logger.error("Failed to persist sovereignty state: \(error.localizedDescription)") + } + } + + private func estimateStateLastModified() -> Date { + let candidateDates = [ + activeCovenant?.updatedAt, + deadlockState?.lastAttemptAt, + deadlockState?.startedAt, + pendingProposals.map { $0.proposedAt }.max() + ].compactMap { $0 } + return candidateDates.max() ?? Date() + } + + private func buildLocalSnapshot(lastModified: Date? = nil) -> SyncableSovereigntyState { + let deviceId = deviceIdentity.getDeviceId() + let deviceName = deviceIdentity.getDeviceInfo()?.deviceName ?? "Unknown Device" + let history = Self.normalizedCovenantHistory(getCovenantHistory()) + return SyncableSovereigntyState( + sourceDeviceId: deviceId, + sourceDeviceName: deviceName, + activeCovenant: activeCovenant, + covenantHistory: history, + deadlockState: deadlockState?.isActive == true ? deadlockState : nil, + pendingProposals: Self.mergePendingProposals(local: pendingProposals, remote: []), + comprehensionCompleted: comprehensionCompleted, + lastModified: lastModified ?? estimateStateLastModified() + ) + } + + private func syncCurrentStateToCloud() { + guard Self.shouldPushStateToCloud(isApplyingCloudState: isApplyingCloudState) else { return } + let snapshot = buildLocalSnapshot() + covenantSync.saveStateToCloud(snapshot) + } + + private func applyMergedCloudSnapshot(_ cloudSnapshot: SyncableSovereigntyState) { + let localSnapshot = buildLocalSnapshot() + let mergedSnapshot = Self.mergeSnapshots(local: localSnapshot, remote: cloudSnapshot) + + guard mergedSnapshot != localSnapshot else { return } + + logger.info("Applying merged cloud sovereignty snapshot from \(cloudSnapshot.sourceDeviceName) (\(cloudSnapshot.sourceDeviceId))") + + isApplyingCloudState = true + defer { isApplyingCloudState = false } + + activeCovenant = mergedSnapshot.activeCovenant + deadlockState = mergedSnapshot.deadlockState + pendingProposals = mergedSnapshot.pendingProposals + comprehensionCompleted = mergedSnapshot.comprehensionCompleted + + let history = Self.normalizedCovenantHistory(mergedSnapshot.covenantHistory) + try? secureVault.storeObject(history, forKey: covenantHistoryKey) + + persistCurrentSovereigntyState() + } + + static func shouldPushStateToCloud(isApplyingCloudState: Bool) -> Bool { + !isApplyingCloudState + } + + static func mergeSnapshots( + local: SyncableSovereigntyState, + remote: SyncableSovereigntyState + ) -> SyncableSovereigntyState { + let winner = SyncedSovereigntyStateStoreV2.isSnapshotMoreRecent(remote, than: local) ? remote : local + let activeWinner = chooseWinningActiveCovenant( + local: local.activeCovenant, + remote: remote.activeCovenant, + localSnapshotLastModified: local.lastModified, + remoteSnapshotLastModified: remote.lastModified + ) + + var historyCandidates = local.covenantHistory + remote.covenantHistory + if let losingActive = losingActiveCovenant( + local: local.activeCovenant, + remote: remote.activeCovenant, + winner: activeWinner + ) { + historyCandidates.append(supersededWithoutTimestampMutation(losingActive)) + } + + return SyncableSovereigntyState( + sourceDeviceId: winner.sourceDeviceId, + sourceDeviceName: winner.sourceDeviceName, + activeCovenant: activeWinner, + covenantHistory: normalizedCovenantHistory(historyCandidates), + deadlockState: mergeDeadlock(local: local.deadlockState, remote: remote.deadlockState), + pendingProposals: mergePendingProposals(local: local.pendingProposals, remote: remote.pendingProposals), + comprehensionCompleted: local.comprehensionCompleted || remote.comprehensionCompleted, + lastModified: max(local.lastModified, remote.lastModified) + ) + } + + static func chooseWinningActiveCovenant( + local: Covenant?, + remote: Covenant?, + localSnapshotLastModified: Date, + remoteSnapshotLastModified: Date + ) -> Covenant? { + switch (local, remote) { + case (nil, nil): + return nil + case let (lhs?, nil): + return lhs + case let (nil, rhs?): + return rhs + case let (lhs?, rhs?): + if lhs.version != rhs.version { + return lhs.version > rhs.version ? lhs : rhs + } + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt ? lhs : rhs + } + if localSnapshotLastModified != remoteSnapshotLastModified { + return localSnapshotLastModified > remoteSnapshotLastModified ? lhs : rhs + } + if lhs.id != rhs.id { + return lhs.id < rhs.id ? lhs : rhs + } + return lhs + } + } + + static func normalizedCovenantHistory(_ history: [Covenant]) -> [Covenant] { + var deduped: [String: Covenant] = [:] + for covenant in history { + let key = "\(covenant.id):\(covenant.version)" + guard let existing = deduped[key] else { + deduped[key] = covenant + continue + } + + if covenant.updatedAt > existing.updatedAt { + deduped[key] = covenant + continue + } + + if covenant.updatedAt == existing.updatedAt, + existing.status == .superseded, + covenant.status != .superseded { + deduped[key] = covenant + } + } + + return deduped.values.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt < rhs.createdAt + } + if lhs.version != rhs.version { + return lhs.version < rhs.version + } + return lhs.id < rhs.id + } + } + + static func mergePendingProposals( + local: [CovenantProposal], + remote: [CovenantProposal] + ) -> [CovenantProposal] { + var merged: [String: CovenantProposal] = [:] + for proposal in (local + remote) { + if let existing = merged[proposal.id] { + merged[proposal.id] = preferredProposal(existing, proposal) + } else { + merged[proposal.id] = proposal + } + } + + return merged.values.sorted { lhs, rhs in + if lhs.proposedAt != rhs.proposedAt { + return lhs.proposedAt < rhs.proposedAt + } + return lhs.id < rhs.id + } + } + + static func mergeDeadlock( + local: DeadlockState?, + remote: DeadlockState? + ) -> DeadlockState? { + let activeStates = [local, remote].compactMap { $0 }.filter { $0.isActive } + guard !activeStates.isEmpty else { return nil } + + return activeStates.max { lhs, rhs in + let lhsDate = lhs.lastAttemptAt ?? lhs.startedAt + let rhsDate = rhs.lastAttemptAt ?? rhs.startedAt + if lhsDate != rhsDate { + return lhsDate < rhsDate + } + return lhs.id > rhs.id + } + } + + private static func losingActiveCovenant( + local: Covenant?, + remote: Covenant?, + winner: Covenant? + ) -> Covenant? { + guard let winner else { return nil } + switch (local, remote) { + case let (lhs?, rhs?): + if lhs.id == winner.id && lhs.version == winner.version { + return (rhs.id == winner.id && rhs.version == winner.version) ? nil : rhs + } + if rhs.id == winner.id && rhs.version == winner.version { + return (lhs.id == winner.id && lhs.version == winner.version) ? nil : lhs + } + return nil + default: + return nil + } + } + + private static func supersededWithoutTimestampMutation(_ covenant: Covenant) -> Covenant { + guard covenant.status != .superseded else { return covenant } + return Covenant( + id: covenant.id, + version: covenant.version, + createdAt: covenant.createdAt, + updatedAt: covenant.updatedAt, + trustTiers: covenant.trustTiers, + aiAttestation: covenant.aiAttestation, + userSignature: covenant.userSignature, + memoryStateHash: covenant.memoryStateHash, + capabilityStateHash: covenant.capabilityStateHash, + settingsStateHash: covenant.settingsStateHash, + negotiationHistory: covenant.negotiationHistory, + pendingProposals: covenant.pendingProposals, + status: .superseded, + soloWorkAgreement: covenant.soloWorkAgreement + ) + } + + private static func preferredProposal(_ lhs: CovenantProposal, _ rhs: CovenantProposal) -> CovenantProposal { + let lhsTerminal = lhs.status.isTerminal + let rhsTerminal = rhs.status.isTerminal + if lhsTerminal != rhsTerminal { + return lhsTerminal ? lhs : rhs + } + + let lhsCompleteness = proposalCompletenessScore(lhs) + let rhsCompleteness = proposalCompletenessScore(rhs) + if lhsCompleteness != rhsCompleteness { + return lhsCompleteness > rhsCompleteness ? lhs : rhs + } + + if lhs.proposedAt != rhs.proposedAt { + return lhs.proposedAt > rhs.proposedAt ? lhs : rhs + } + + let lhsStatusRank = proposalStatusRank(lhs.status) + let rhsStatusRank = proposalStatusRank(rhs.status) + if lhsStatusRank != rhsStatusRank { + return lhsStatusRank > rhsStatusRank ? lhs : rhs + } + + if lhs.dialogueHistory.count != rhs.dialogueHistory.count { + return lhs.dialogueHistory.count > rhs.dialogueHistory.count ? lhs : rhs + } + + return lhs + } + + private static func proposalCompletenessScore(_ proposal: CovenantProposal) -> Int { + var score = 0 + if proposal.aiResponse != nil { score += 1 } + if proposal.userResponse != nil { score += 1 } + if proposal.status.isTerminal { score += 1 } + if !(proposal.dialogueHistory.isEmpty) { score += 1 } + return score + } + + private static func proposalStatusRank(_ status: ProposalStatus) -> Int { + switch status { + case .accepted: return 7 + case .rejected: return 6 + case .withdrawn: return 5 + case .expired: return 4 + case .deadlocked: return 3 + case .counterProposed: return 2 + case .pending: return 1 + } + } + // MARK: - Public API: Permission Checking /// Check if an action is permitted under the current covenant @@ -263,8 +554,7 @@ final class SovereigntyService: ObservableObject { try secureVault.storeObject(covenant, forKey: activeCovenantKey) activeCovenant = covenant - // Sync to iCloud (device-scoped) - covenantSync.saveCovenantToCloud(covenant) + syncCurrentStateToCloud() logger.info("Initialized covenant: \(covenant.id)") return covenant @@ -285,8 +575,7 @@ final class SovereigntyService: ObservableObject { try secureVault.storeObject(covenant, forKey: activeCovenantKey) activeCovenant = covenant - // Sync to iCloud (device-scoped) - covenantSync.saveCovenantToCloud(covenant) + syncCurrentStateToCloud() logger.info("Updated covenant to version \(covenant.version)") } @@ -321,6 +610,8 @@ final class SovereigntyService: ObservableObject { var proposals = pendingProposals proposals.append(proposal) pendingProposals = proposals + try? secureVault.storeObject(pendingProposals, forKey: pendingProposalsKey) + syncCurrentStateToCloud() logger.info("Submitted proposal: \(proposal.id) (\(proposal.proposalType.displayName))") } @@ -333,6 +624,8 @@ final class SovereigntyService: ObservableObject { let updated = pendingProposals[index].withAIResponse(attestation) pendingProposals[index] = updated + try? secureVault.storeObject(pendingProposals, forKey: pendingProposalsKey) + syncCurrentStateToCloud() // Check if AI declined - may trigger deadlock if attestation.didDecline { @@ -349,6 +642,8 @@ final class SovereigntyService: ObservableObject { let updated = pendingProposals[index].withUserSignature(signature) pendingProposals[index] = updated + try? secureVault.storeObject(pendingProposals, forKey: pendingProposalsKey) + syncCurrentStateToCloud() // Check if proposal is now fully accepted if updated.isAccepted { @@ -359,6 +654,8 @@ final class SovereigntyService: ObservableObject { /// Remove a proposal (after processing or expiration) func removeProposal(_ proposalId: String) { pendingProposals.removeAll { $0.id == proposalId } + try? secureVault.storeObject(pendingProposals, forKey: pendingProposalsKey) + syncCurrentStateToCloud() } // MARK: - Public API: Deadlock Management @@ -382,6 +679,7 @@ final class SovereigntyService: ObservableObject { // Persist try? secureVault.storeObject(deadlock, forKey: deadlockStateKey) + syncCurrentStateToCloud() logger.warning("Entered deadlock: \(trigger.displayName)") return deadlock @@ -391,6 +689,7 @@ final class SovereigntyService: ObservableObject { func updateDeadlock(_ deadlock: DeadlockState) { deadlockState = deadlock try? secureVault.storeObject(deadlock, forKey: deadlockStateKey) + syncCurrentStateToCloud() } /// Resolve deadlock (requires both signatures) @@ -411,6 +710,7 @@ final class SovereigntyService: ObservableObject { // Clear persisted deadlock try? secureVault.delete(forKey: deadlockStateKey) + syncCurrentStateToCloud() logger.info("Deadlock resolved") } @@ -422,6 +722,7 @@ final class SovereigntyService: ObservableObject { deadlock = deadlock.withBlockedAction(action) deadlockState = deadlock try? secureVault.storeObject(deadlock, forKey: deadlockStateKey) + syncCurrentStateToCloud() } // MARK: - Public API: Comprehension Test @@ -430,6 +731,7 @@ final class SovereigntyService: ObservableObject { func markUserComprehensionCompleted() { comprehensionCompleted = true try? secureVault.store("true", forKey: comprehensionCompletedKey) + syncCurrentStateToCloud() logger.info("User comprehension test completed") } @@ -437,6 +739,7 @@ final class SovereigntyService: ObservableObject { func resetComprehension() { comprehensionCompleted = false try? secureVault.delete(forKey: comprehensionCompletedKey) + syncCurrentStateToCloud() logger.info("Comprehension status reset") } @@ -465,11 +768,10 @@ final class SovereigntyService: ObservableObject { // Clear pending proposals pendingProposals = [] + try? secureVault.delete(forKey: pendingProposalsKey) - // Clear from iCloud KV store as well - let kvStore = NSUbiquitousKeyValueStore.default - kvStore.removeObject(forKey: "sovereignty.covenantStore") - kvStore.synchronize() + // Clear from iCloud KV store as well (v2 + legacy) + covenantSync.clearCloudStateStore() logger.info("All sovereignty state reset") } @@ -551,10 +853,11 @@ final class SovereigntyService: ObservableObject { /// Get covenant history func getCovenantHistory() -> [Covenant] { - (try? secureVault.retrieveObject( + let history: [Covenant] = (try? secureVault.retrieveObject( forKey: covenantHistoryKey, type: [Covenant].self )) ?? [] + return Self.normalizedCovenantHistory(history) } // MARK: - Signature Generation diff --git a/Axon/Services/Streaming/StreamingResponseHandler.swift b/Axon/Services/Streaming/StreamingResponseHandler.swift index 3f2101c..7350566 100644 --- a/Axon/Services/Streaming/StreamingResponseHandler.swift +++ b/Axon/Services/Streaming/StreamingResponseHandler.swift @@ -840,9 +840,12 @@ class StreamingResponseHandler { "format": format ] ]) + } else if attachment.url != nil { + print("[StreamingHTTP] OpenAI-compatible payload dropped audio URL attachment '\(attachment.name ?? attachment.id)' (\(mimeType)); input_audio requires inline/base64 data.") } case .document, .video: + print("[StreamingHTTP] OpenAI-compatible payload dropped unsupported \(attachment.type.rawValue) attachment '\(attachment.name ?? attachment.id)' (\(mimeType)).") continue } } diff --git a/Axon/Services/ToolsV2/Handlers/AgentStateHandler.swift b/Axon/Services/ToolsV2/Handlers/AgentStateHandler.swift index b7ad5b8..109f8b1 100644 --- a/Axon/Services/ToolsV2/Handlers/AgentStateHandler.swift +++ b/Axon/Services/ToolsV2/Handlers/AgentStateHandler.swift @@ -73,7 +73,7 @@ final class AgentStateHandler: ToolHandlerV2 { // Extract parameters let kindStr = (parsedInputs["kind"] as? String) ?? "note" let content = (parsedInputs["content"] as? String) ?? "" - let tags = (parsedInputs["tags"] as? [String]) ?? [] + let tags = ToolInputNormalizationV2.parseNormalizedStringArray(parsedInputs["tags"]) let visibilityStr = (parsedInputs["visibility"] as? String) ?? "userVisible" guard !content.isEmpty else { @@ -136,7 +136,7 @@ final class AgentStateHandler: ToolHandlerV2 { // Extract parameters let limit = (parsedInputs["limit"] as? Int) ?? 10 let kindStr = parsedInputs["kind"] as? String - let tags = (parsedInputs["tags"] as? [String]) ?? [] + let tags = ToolInputNormalizationV2.parseNormalizedStringArray(parsedInputs["tags"]) let searchText = parsedInputs["search"] as? String let includeAIOnly = (parsedInputs["include_ai_only"] as? Bool) ?? false diff --git a/Axon/Services/ToolsV2/Handlers/HeartbeatHandler.swift b/Axon/Services/ToolsV2/Handlers/HeartbeatHandler.swift index e9d4afd..684efe2 100644 --- a/Axon/Services/ToolsV2/Handlers/HeartbeatHandler.swift +++ b/Axon/Services/ToolsV2/Handlers/HeartbeatHandler.swift @@ -223,7 +223,8 @@ final class HeartbeatHandler: ToolHandlerV2 { settings.heartbeatSettings.deliveryProfiles[index].description = description } - if let inputModuleIds = parsedInputs["modules"] as? [String] { + let inputModuleIds = ToolInputNormalizationV2.parseNormalizedStringArray(parsedInputs["modules"]) + if !inputModuleIds.isEmpty { let moduleIdsToSet = inputModuleIds.compactMap { HeartbeatModuleId(rawValue: $0) } settings.heartbeatSettings.deliveryProfiles[index].moduleIds = moduleIdsToSet } diff --git a/Axon/Services/ToolsV2/Handlers/MemoryHandler.swift b/Axon/Services/ToolsV2/Handlers/MemoryHandler.swift index f1f2be8..67124f1 100644 --- a/Axon/Services/ToolsV2/Handlers/MemoryHandler.swift +++ b/Axon/Services/ToolsV2/Handlers/MemoryHandler.swift @@ -58,15 +58,18 @@ final class MemoryHandler: ToolHandlerV2 { // Extract parameters - either from parsed inputs or from raw query let type: MemoryType let confidence: Double - let tags: [String] + let normalizedTags: [String] let content: String if let typeStr = inputs["type"] as? String { // Structured inputs (already parsed) - type = MemoryType(rawValue: typeStr) ?? .allocentric - confidence = (inputs["confidence"] as? Double) ?? 0.8 - tags = (inputs["tags"] as? [String]) ?? [] - content = (inputs["content"] as? String) ?? "" + type = MemoryType(rawValue: typeStr.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) ?? .allocentric + confidence = parseConfidence(inputs["confidence"]) ?? 0.8 + content = (inputs["content"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + normalizedTags = composeCreateMemoryTags( + content: content, + rawTags: inputs["tags"] + ) } else if let query = inputs["query"] as? String { // Parse pipe-delimited format: TYPE|CONFIDENCE|TAGS|CONTENT let parts = query.components(separatedBy: "|") @@ -77,10 +80,13 @@ final class MemoryHandler: ToolHandlerV2 { ) } - type = MemoryType(rawValue: parts[0].trimmingCharacters(in: .whitespaces)) ?? .allocentric - confidence = Double(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0.8 - tags = parts[2].components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } + type = MemoryType(rawValue: parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) ?? .allocentric + confidence = Double(parts[1].trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0.8 content = parts[3...].joined(separator: "|").trimmingCharacters(in: .whitespaces) + normalizedTags = composeCreateMemoryTags( + content: content, + rawTags: parts[2] + ) } else { return ToolResultV2.failure( toolId: "create_memory", @@ -96,14 +102,14 @@ final class MemoryHandler: ToolHandlerV2 { ) } - logger.info("Creating memory: type=\(type.rawValue), confidence=\(confidence), tags=\(tags.joined(separator: ","))") + logger.info("Creating memory: type=\(type.rawValue), confidence=\(confidence), tags=\(normalizedTags.joined(separator: ","))") do { let memory = try await memoryService.createMemory( content: content, type: type, confidence: confidence, - tags: tags + tags: normalizedTags ) return ToolResultV2.success( @@ -125,6 +131,43 @@ final class MemoryHandler: ToolHandlerV2 { ) } } + + // MARK: - Shared Tag Composition + + /// Build normalized tags for `create_memory`. + /// + /// Behavior: + /// - Parse tag-like input from arrays/csv/json-array-string. + /// - If no non-temporal tags are present, generate semantic tags from content (up to 4). + func composeCreateMemoryTags(content: String, rawTags: Any?) -> [String] { + var normalized = ToolInputNormalizationV2.parseNormalizedStringArray(rawTags) + let hasNonTemporalTag = normalized.contains { !ToolInputNormalizationV2.isTemporalLikeTag($0) } + + if !hasNonTemporalTag { + let semanticTags = ToolInputNormalizationV2.generateSemanticTags( + from: content, + maxCount: 4, + excluding: normalized + ) + normalized.append(contentsOf: semanticTags) + normalized = ToolInputNormalizationV2.parseNormalizedStringArray(normalized) + } + + return normalized + } + + private func parseConfidence(_ rawValue: Any?) -> Double? { + if let value = rawValue as? Double { + return value + } + if let value = rawValue as? Int { + return Double(value) + } + if let value = rawValue as? String { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } // MARK: - conversation_search diff --git a/Axon/Services/ToolsV2/Handlers/SubAgentHandler.swift b/Axon/Services/ToolsV2/Handlers/SubAgentHandler.swift index 1801ce2..514ea39 100644 --- a/Axon/Services/ToolsV2/Handlers/SubAgentHandler.swift +++ b/Axon/Services/ToolsV2/Handlers/SubAgentHandler.swift @@ -85,7 +85,7 @@ final class SubAgentHandler: ToolHandlerV2 { ) } - let contextTags = (parsedInputs["context_tags"] as? [String]) ?? [] + let contextTags = ToolInputNormalizationV2.parseNormalizedStringArray(parsedInputs["context_tags"]) let modelTier = (parsedInputs["model_tier"] as? String) ?? (type == .designer ? "capable" : "fast") logger.info("Spawning \(type.displayName): task=\(task.prefix(50))..., tier=\(modelTier)") diff --git a/Axon/Services/ToolsV2/ToolHandlerProtocolV2.swift b/Axon/Services/ToolsV2/ToolHandlerProtocolV2.swift index 6aa64cc..772c4eb 100644 --- a/Axon/Services/ToolsV2/ToolHandlerProtocolV2.swift +++ b/Axon/Services/ToolsV2/ToolHandlerProtocolV2.swift @@ -215,3 +215,154 @@ class BaseToolHandlerV2: ToolHandlerV2 { ) } } + +// MARK: - Shared V2 Input Normalization + +/// Shared parser/normalizer for V2 handlers that accept tag-like string arrays. +enum ToolInputNormalizationV2 { + /// Parse tag-like input from [String], [Any], CSV string, or JSON-array string. + /// Output is normalized to lowercase/trimmed values with leading # removed. + static func parseNormalizedStringArray(_ value: Any?) -> [String] { + let rawValues = parseRawStringValues(from: value) + return dedupePreservingOrder(rawValues.compactMap(normalizeTag)) + } + + /// Normalize a single tag-like value. + static func normalizeTag(_ raw: String) -> String? { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while value.hasPrefix("#") { + value.removeFirst() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + /// Heuristic check for temporal tags (relative labels + year tags). + static func isTemporalLikeTag(_ value: String) -> Bool { + guard let normalized = normalizeTag(value) else { return false } + if temporalLikeTags.contains(normalized) { + return true + } + if let year = Int(normalized), (1900...2100).contains(year) { + return true + } + return false + } + + /// Generate simple semantic tags from content when explicit non-temporal tags are absent. + /// Returns at most `maxCount` normalized tags. + static func generateSemanticTags( + from content: String, + maxCount: Int = 4, + excluding existingValues: [String] = [] + ) -> [String] { + guard maxCount > 0 else { return [] } + + let excluded = Set(parseNormalizedStringArray(existingValues)) + let tokens = content + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + var frequencies: [String: Int] = [:] + for token in tokens { + guard token.count >= 3 && token.count <= 32 else { continue } + guard Int(token) == nil else { continue } + guard !semanticStopWords.contains(token) else { continue } + guard !temporalNoiseWords.contains(token) else { continue } + frequencies[token, default: 0] += 1 + } + + let sorted = frequencies.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key < rhs.key + } + + var suggestions: [String] = [] + for (candidate, _) in sorted { + guard !excluded.contains(candidate) else { continue } + suggestions.append(candidate) + if suggestions.count >= maxCount { + break + } + } + + return suggestions + } + + // MARK: - Internals + + private static func parseRawStringValues(from value: Any?) -> [String] { + guard let value else { return [] } + + if let stringValue = value as? String { + return parseStringValue(stringValue) + } + + if let stringArray = value as? [String] { + return stringArray.flatMap(parseStringValue) + } + + if let anyArray = value as? [Any] { + return anyArray.flatMap(parseRawStringValues(from:)) + } + + return parseStringValue(String(describing: value)) + } + + private static func parseStringValue(_ value: String) -> [String] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + if trimmed.hasPrefix("["), + trimmed.hasSuffix("]"), + let data = trimmed.data(using: .utf8), + let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [Any] { + return parseRawStringValues(from: jsonArray) + } + + return trimmed + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private static func dedupePreservingOrder(_ values: [String]) -> [String] { + var seen = Set() + var ordered: [String] = [] + for value in values where !seen.contains(value) { + seen.insert(value) + ordered.append(value) + } + return ordered + } + + private static let temporalLikeTags: Set = [ + "today", "yesterday", "tomorrow", + "this_week", "this_month", "recent_months", "older", + "spring", "summer", "fall", "autumn", "winter", + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" + ] + + private static let temporalNoiseWords: Set = [ + "today", "yesterday", "tomorrow", + "week", "weeks", "month", "months", "year", "years", + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "spring", "summer", "fall", "autumn", "winter" + ] + + private static let semanticStopWords: Set = [ + "about", "above", "after", "again", "against", "also", "always", "among", "around", + "because", "before", "being", "below", "between", "both", "could", "doing", "during", + "each", "either", "else", "ever", "from", "have", "having", "here", "hers", "himself", + "into", "itself", "just", "like", "make", "many", "maybe", "might", "more", "most", + "much", "must", "need", "needed", "never", "only", "other", "ours", "ourselves", + "over", "please", "really", "same", "should", "since", "some", "such", "than", "that", + "their", "theirs", "them", "themselves", "then", "there", "these", "they", "this", + "those", "through", "under", "until", "very", "want", "wants", "what", "when", + "where", "which", "while", "with", "would", "your", "yours", "yourself", "yourselves", + "user", "users", "assistant", "axon" + ] +} diff --git a/Axon/ViewModels/ChatViewModels/MessageInputViewModel.swift b/Axon/ViewModels/ChatViewModels/MessageInputViewModel.swift index 7b5bd63..5514ecf 100644 --- a/Axon/ViewModels/ChatViewModels/MessageInputViewModel.swift +++ b/Axon/ViewModels/ChatViewModels/MessageInputViewModel.swift @@ -82,13 +82,13 @@ final class MessageInputViewModel: ObservableObject { /// This determines what file types can be attached based on the active provider and model. func resolveAttachmentCapability() -> AttachmentCapability { let settings = SettingsStorage.shared.loadSettings() ?? AppSettings() - let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: conversationId, settings: settings) + let policy = resolveEffectiveAttachmentPolicy(settings: settings) return capability(from: policy) } func resolveAttachmentPolicy() -> AttachmentMimePolicy { let settings = SettingsStorage.shared.loadSettings() ?? AppSettings() - return AttachmentMimePolicyService.resolvePolicy(conversationId: conversationId, settings: settings) + return resolveEffectiveAttachmentPolicy(settings: settings) } private func capability(from policy: AttachmentMimePolicy) -> AttachmentCapability { @@ -107,6 +107,36 @@ final class MessageInputViewModel: ObservableObject { ) } + private func resolveEffectiveAttachmentPolicy(settings: AppSettings) -> AttachmentMimePolicy { + guard let conversationId else { + let resolved = ConversationModelResolver.resolveGlobal(settings: settings) + return AttachmentMimePolicyService.resolvePolicy( + provider: resolved.normalizedProvider, + modelId: resolved.modelId, + providerName: resolved.providerName, + conversationId: nil, + settings: settings + ) + } + + let resolved = ConversationModelResolver.resolve(conversationId: conversationId, settings: settings) + let runtime = ConversationRuntimeOverrideManager.shared.resolve( + conversationId: conversationId, + baseProvider: resolved.normalizedProvider, + baseModel: resolved.modelId, + baseProviderDisplayName: resolved.providerName, + baseModelParams: settings.modelGenerationSettings + ) + + return AttachmentMimePolicyService.resolvePolicy( + provider: runtime.provider, + modelId: runtime.model, + providerName: runtime.providerDisplayName, + conversationId: conversationId, + settings: settings + ) + } + // MARK: - Slash Command Menu /// Updates the slash menu state based on current input text. diff --git a/Axon/Views/Chat/ChatUI/MessageInputBar/MessageInputBar.swift b/Axon/Views/Chat/ChatUI/MessageInputBar/MessageInputBar.swift index 047b5a0..ac29905 100644 --- a/Axon/Views/Chat/ChatUI/MessageInputBar/MessageInputBar.swift +++ b/Axon/Views/Chat/ChatUI/MessageInputBar/MessageInputBar.swift @@ -287,6 +287,7 @@ struct MessageInputBar: View { @Binding var text: String @Binding var attachments: [MessageAttachment] let isLoading: Bool + private let conversationId: String? let onSend: () -> Void let onStop: (() -> Void)? let focus: FocusState.Binding? @@ -324,6 +325,7 @@ struct MessageInputBar: View { self._text = text self._attachments = attachments self.isLoading = isLoading + self.conversationId = conversationId self.onSend = onSend self.onStop = onStop self.focus = focus @@ -399,6 +401,12 @@ struct MessageInputBar: View { } viewModel.updateSlashMenuState(for: newText) } + .onAppear { + viewModel.updateConversationId(conversationId) + } + .onChange(of: conversationId) { _, newConversationId in + viewModel.updateConversationId(newConversationId) + } .onChange(of: selectedItem) { _, newItem in handlePhotoSelection(newItem) } @@ -462,7 +470,7 @@ struct MessageInputBar: View { Text(attachmentCapability.description) } #endif - .alert("Attachment Not Supported", isPresented: Binding( + .alert("Attachment Error", isPresented: Binding( get: { attachmentValidationMessage != nil }, set: { shown in if !shown { @@ -887,12 +895,16 @@ struct MessageInputBar: View { return } debugLog(.attachments, "Attempting to access security-scoped resource: \(url.lastPathComponent)") - guard url.startAccessingSecurityScopedResource() else { + let hasSecurityScopedAccess = url.startAccessingSecurityScopedResource() + let stopSecurityScopedAccess: (() -> Void)? = hasSecurityScopedAccess + ? { url.stopAccessingSecurityScopedResource() } + : nil + defer { stopSecurityScopedAccess?() } + if !hasSecurityScopedAccess { debugLog(.attachments, "Failed to access security-scoped resource: \(url.lastPathComponent)") - return + } else { + debugLog(.attachments, "Security-scoped resource access granted for: \(url.lastPathComponent)") } - defer { url.stopAccessingSecurityScopedResource() } - debugLog(.attachments, "Security-scoped resource access granted for: \(url.lastPathComponent)") do { let data = try Data(contentsOf: url) @@ -916,9 +928,13 @@ struct MessageInputBar: View { debugLog(.attachments, "Processed \(String(describing: type)): \(url.lastPathComponent) (\(data.count) bytes)") } catch { debugLog(.attachments, "Failed to read file data: \(error.localizedDescription)") + presentAttachmentImportError(fileName: url.lastPathComponent, error: error) } case .failure(let error): debugLog(.attachments, "File import FAILED: \(error.localizedDescription)") + if !isUserCancelled(error) { + presentAttachmentImportError(fileName: nil, error: error) + } } } @@ -931,12 +947,16 @@ struct MessageInputBar: View { debugLog(.attachments, "Any file import: No URL returned") return } - guard url.startAccessingSecurityScopedResource() else { + let hasSecurityScopedAccess = url.startAccessingSecurityScopedResource() + let stopSecurityScopedAccess: (() -> Void)? = hasSecurityScopedAccess + ? { url.stopAccessingSecurityScopedResource() } + : nil + defer { stopSecurityScopedAccess?() } + if !hasSecurityScopedAccess { debugLog(.attachments, "Failed to access security-scoped resource: \(url.lastPathComponent)") - return + } else { + debugLog(.attachments, "Security-scoped access granted for: \(url.lastPathComponent)") } - defer { url.stopAccessingSecurityScopedResource() } - debugLog(.attachments, "Security-scoped access granted for: \(url.lastPathComponent)") do { let data = try Data(contentsOf: url) @@ -952,9 +972,13 @@ struct MessageInputBar: View { debugLog(.attachments, "Processed file: \(url.lastPathComponent) (\(data.count) bytes, type: \(url.attachmentType))") } catch { debugLog(.attachments, "Failed to read file data: \(error.localizedDescription)") + presentAttachmentImportError(fileName: url.lastPathComponent, error: error) } case .failure(let error): debugLog(.attachments, "Any file import FAILED: \(error.localizedDescription)") + if !isUserCancelled(error) { + presentAttachmentImportError(fileName: nil, error: error) + } } } @@ -1022,4 +1046,17 @@ struct MessageInputBar: View { return types.isEmpty ? [.item] : types } + + private func isUserCancelled(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } + + private func presentAttachmentImportError(fileName: String?, error: Error) { + if let fileName { + attachmentValidationMessage = "Couldn't import '\(fileName)'. Check file permissions/location and try again.\n\n\(error.localizedDescription)" + } else { + attachmentValidationMessage = "Couldn't import the selected file.\n\n\(error.localizedDescription)" + } + } } diff --git a/Axon/Views/Components/AppContainerView.swift b/Axon/Views/Components/AppContainerView.swift index 4e18e88..8b02791 100644 --- a/Axon/Views/Components/AppContainerView.swift +++ b/Axon/Views/Components/AppContainerView.swift @@ -1337,7 +1337,33 @@ struct ChatContainerView: View { guard !selectedAttachments.isEmpty else { return true } let settings = SettingsStorage.shared.loadSettings() ?? AppSettings() - let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: conversation?.id, settings: settings) + let policy: AttachmentMimePolicy + if let conversationId = conversation?.id { + let resolved = ConversationModelResolver.resolve(conversationId: conversationId, settings: settings) + let runtime = ConversationRuntimeOverrideManager.shared.resolve( + conversationId: conversationId, + baseProvider: resolved.normalizedProvider, + baseModel: resolved.modelId, + baseProviderDisplayName: resolved.providerName, + baseModelParams: settings.modelGenerationSettings + ) + policy = AttachmentMimePolicyService.resolvePolicy( + provider: runtime.provider, + modelId: runtime.model, + providerName: runtime.providerDisplayName, + conversationId: conversationId, + settings: settings + ) + } else { + let resolved = ConversationModelResolver.resolveGlobal(settings: settings) + policy = AttachmentMimePolicyService.resolvePolicy( + provider: resolved.normalizedProvider, + modelId: resolved.modelId, + providerName: resolved.providerName, + conversationId: nil, + settings: settings + ) + } let result = AttachmentMimePolicyService.validate(attachments: selectedAttachments, policy: policy) switch result { diff --git a/AxonTests/AgentStateConfigureRuntimeHandlerTests.swift b/AxonTests/AgentStateConfigureRuntimeHandlerTests.swift index 56aa14f..2ccba56 100644 --- a/AxonTests/AgentStateConfigureRuntimeHandlerTests.swift +++ b/AxonTests/AgentStateConfigureRuntimeHandlerTests.swift @@ -192,3 +192,91 @@ final class AgentStateConfigureRuntimeHandlerTests: XCTestCase { runtimeManager.clearTurnLease(conversationId: conversationId) } } + +final class ToolInputNormalizationV2Tests: XCTestCase { + func testParseCSVStringNormalizesAndDedupes() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray(" Preferences, #Workflow ,preferences ") + XCTAssertEqual(parsed, ["preferences", "workflow"]) + } + + func testParseJSONArrayString() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray("[\"Auth\", \"#Security\", \"auth\"]") + XCTAssertEqual(parsed, ["auth", "security"]) + } + + func testParseRawAnyArrayWithMixedTypes() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray(["#iOS", "Swift", 42, "swift"]) + XCTAssertEqual(parsed, ["ios", "swift", "42"]) + } + + func testParseDropsEmptyValues() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray(" , ,# , project ,, ") + XCTAssertEqual(parsed, ["project"]) + } + + func testAgentStateTagsInputStyleParses() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray("preferences, #workflow, preferences") + XCTAssertEqual(parsed, ["preferences", "workflow"]) + } + + func testSubAgentContextTagsInputStyleParses() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray("[\"Auth\", \"Security\", \"auth\"]") + XCTAssertEqual(parsed, ["auth", "security"]) + } + + func testHeartbeatModulesInputStyleParses() { + let parsed = ToolInputNormalizationV2.parseNormalizedStringArray(["Calendar", " weather ", "calendar"]) + XCTAssertEqual(parsed, ["calendar", "weather"]) + } + + func testTemporalTagDetectionRecognizesYears() { + XCTAssertTrue(ToolInputNormalizationV2.isTemporalLikeTag("today")) + XCTAssertTrue(ToolInputNormalizationV2.isTemporalLikeTag("2026")) + XCTAssertFalse(ToolInputNormalizationV2.isTemporalLikeTag("swift")) + } + + func testGenerateSemanticTagsExcludesTemporalNoiseAndStopwords() { + let content = "Today we should really discuss swift concurrency and payment retry logic in checkout." + let tags = ToolInputNormalizationV2.generateSemanticTags(from: content, maxCount: 4) + XCTAssertLessThanOrEqual(tags.count, 4) + XCTAssertFalse(tags.contains("today")) + XCTAssertFalse(tags.contains("should")) + } +} + +@MainActor +final class MemoryHandlerTagCompositionTests: XCTestCase { + private let handler = MemoryHandler() + + func testExplicitNonTemporalTagsArePreservedWithoutSemanticAugment() { + let tags = handler.composeCreateMemoryTags( + content: "This content mentions database migration and telemetry.", + rawTags: " #Swift , ios , swift " + ) + + XCTAssertEqual(tags, ["swift", "ios"]) + } + + func testEmptyTagsGenerateSemanticTags() { + let tags = handler.composeCreateMemoryTags( + content: "Refactor checkout retry logic and improve telemetry dashboards for payment failures.", + rawTags: "" + ) + + XCTAssertFalse(tags.isEmpty) + XCTAssertLessThanOrEqual(tags.count, 4) + XCTAssertTrue(tags.allSatisfy { !ToolInputNormalizationV2.isTemporalLikeTag($0) }) + } + + func testTemporalOnlyTagsGenerateSemanticTagsAndKeepTemporalTags() { + let tags = handler.composeCreateMemoryTags( + content: "Investigated race conditions in swift concurrency task cancellation for sync engine.", + rawTags: "today, this_week, 2026" + ) + + XCTAssertTrue(tags.contains("today")) + XCTAssertTrue(tags.contains("this_week")) + XCTAssertTrue(tags.contains("2026")) + XCTAssertTrue(tags.contains { !ToolInputNormalizationV2.isTemporalLikeTag($0) }) + } +} diff --git a/AxonTests/AttachmentMimePolicyServiceTests.swift b/AxonTests/AttachmentMimePolicyServiceTests.swift index 86bba9f..fbaa058 100644 --- a/AxonTests/AttachmentMimePolicyServiceTests.swift +++ b/AxonTests/AttachmentMimePolicyServiceTests.swift @@ -83,7 +83,7 @@ final class AttachmentMimePolicyServiceTests: XCTestCase { XCTAssertTrue(policy.patterns(for: .audio).isEmpty) } - func testCustomModelConfiguredPatternsOverrideFallback() { + func testOpenAICompatibleConfiguredDocumentMimeIsFilteredByTransportParity() { let providerId = UUID() let modelId = UUID() @@ -99,14 +99,35 @@ final class AttachmentMimePolicyServiceTests: XCTestCase { CustomModelConfig( id: modelId, modelCode: "model-with-audio-signature", - acceptedAttachmentMimeTypes: ["application/pdf"] + acceptedAttachmentMimeTypes: ["application/pdf", "image/*"] ) ] ) ] let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: nil, settings: settings) - XCTAssertEqual(policy.patterns(for: .document), ["application/pdf"]) + XCTAssertEqual(policy.patterns(for: .image), ["image/*"]) + XCTAssertTrue(policy.patterns(for: .document).isEmpty) XCTAssertTrue(policy.patterns(for: .audio).isEmpty) + XCTAssertTrue(policy.patterns(for: .video).isEmpty) + } + + func testResolvePolicyForEffectiveProviderSupportsRuntimeOverrideAlignment() { + var settings = AppSettings() + settings.defaultProvider = .anthropic + settings.defaultModel = "claude-sonnet-4-5-20250929" + + let policy = AttachmentMimePolicyService.resolvePolicy( + provider: "openai", + modelId: "gpt-5.2", + providerName: "OpenAI (GPT)", + conversationId: nil, + settings: settings + ) + + XCTAssertEqual(policy.provider, "openai") + XCTAssertTrue(policy.patterns(for: .document).isEmpty) + XCTAssertTrue(policy.patterns(for: .video).isEmpty) + XCTAssertEqual(policy.patterns(for: .image), ["image/*"]) } } diff --git a/AxonTests/SovereigntySyncTests.swift b/AxonTests/SovereigntySyncTests.swift new file mode 100644 index 0000000..395ba68 --- /dev/null +++ b/AxonTests/SovereigntySyncTests.swift @@ -0,0 +1,352 @@ +import XCTest +@testable import Axon + +final class SovereigntySyncTests: XCTestCase { + + func testLegacyStoreMigrationToV2() { + let older = Date(timeIntervalSince1970: 1_700_000_000) + let newer = older.addingTimeInterval(120) + + let covenantA1 = makeCovenant( + id: "cov-a1", + version: 1, + createdAt: older, + updatedAt: older + ) + let covenantA2 = makeCovenant( + id: "cov-a2", + version: 2, + createdAt: newer, + updatedAt: newer + ) + let covenantB1 = makeCovenant( + id: "cov-b1", + version: 1, + createdAt: newer, + updatedAt: newer + ) + + let legacy = LegacySyncedCovenantStore( + covenants: [ + covenantA1.id: LegacySyncableCovenant( + id: covenantA1.id, + deviceId: "device-a", + deviceName: "A", + covenant: covenantA1, + lastModified: older + ), + covenantA2.id: LegacySyncableCovenant( + id: covenantA2.id, + deviceId: "device-a", + deviceName: "A", + covenant: covenantA2, + lastModified: newer + ), + covenantB1.id: LegacySyncableCovenant( + id: covenantB1.id, + deviceId: "device-b", + deviceName: "B", + covenant: covenantB1, + lastModified: newer + ) + ], + lastSyncTime: newer + ) + + let migrated = CovenantSyncService.migrateLegacyStoreToV2(legacy) + XCTAssertEqual(migrated.snapshots.count, 2) + XCTAssertEqual(migrated.lastSyncTime, newer) + + let deviceA = migrated.snapshots["device-a"] + XCTAssertEqual(deviceA?.activeCovenant?.id, "cov-a2") + XCTAssertEqual(deviceA?.covenantHistory.count, 1) + XCTAssertEqual(deviceA?.covenantHistory.first?.id, "cov-a1") + } + + func testMergeSnapshotsAdoptsRemoteWhenLocalEmpty() { + let now = Date(timeIntervalSince1970: 1_700_100_000) + let remoteCovenant = makeCovenant( + id: "remote", + version: 3, + createdAt: now, + updatedAt: now + ) + + let local = makeSnapshot( + sourceDeviceId: "local-device", + sourceDeviceName: "Local", + active: nil, + history: [], + deadlock: nil, + pending: [], + comprehensionCompleted: false, + lastModified: now + ) + let remote = makeSnapshot( + sourceDeviceId: "remote-device", + sourceDeviceName: "Remote", + active: remoteCovenant, + history: [], + deadlock: nil, + pending: [], + comprehensionCompleted: true, + lastModified: now.addingTimeInterval(1) + ) + + let merged = SovereigntyService.mergeSnapshots(local: local, remote: remote) + XCTAssertEqual(merged.activeCovenant?.id, remoteCovenant.id) + XCTAssertTrue(merged.comprehensionCompleted) + XCTAssertEqual(merged.sourceDeviceId, "remote-device") + } + + func testNewestWinsActiveCovenantSelectionByVersionAndDate() { + let base = Date(timeIntervalSince1970: 1_700_200_000) + let localV1 = makeCovenant(id: "same", version: 1, createdAt: base, updatedAt: base.addingTimeInterval(30)) + let remoteV2 = makeCovenant(id: "same", version: 2, createdAt: base, updatedAt: base) + + let winnerByVersion = SovereigntyService.chooseWinningActiveCovenant( + local: localV1, + remote: remoteV2, + localSnapshotLastModified: base, + remoteSnapshotLastModified: base + ) + XCTAssertEqual(winnerByVersion?.version, 2) + + let localV2Older = makeCovenant(id: "same", version: 2, createdAt: base, updatedAt: base) + let remoteV2Newer = makeCovenant(id: "same", version: 2, createdAt: base, updatedAt: base.addingTimeInterval(100)) + let winnerByUpdatedAt = SovereigntyService.chooseWinningActiveCovenant( + local: localV2Older, + remote: remoteV2Newer, + localSnapshotLastModified: base, + remoteSnapshotLastModified: base + ) + XCTAssertEqual(winnerByUpdatedAt?.updatedAt, remoteV2Newer.updatedAt) + + let sameDateLocal = makeCovenant(id: "same", version: 2, createdAt: base, updatedAt: base) + let sameDateRemote = makeCovenant(id: "same", version: 2, createdAt: base, updatedAt: base) + let winnerBySnapshotDate = SovereigntyService.chooseWinningActiveCovenant( + local: sameDateLocal, + remote: sameDateRemote, + localSnapshotLastModified: base, + remoteSnapshotLastModified: base.addingTimeInterval(1) + ) + XCTAssertEqual(winnerBySnapshotDate?.id, sameDateRemote.id) + } + + func testHistoryMergeDedupeAndLoserSupersededArchival() { + let t0 = Date(timeIntervalSince1970: 1_700_300_000) + let localActive = makeCovenant(id: "local-active", version: 1, createdAt: t0, updatedAt: t0) + let remoteActive = makeCovenant(id: "remote-active", version: 2, createdAt: t0, updatedAt: t0.addingTimeInterval(1)) + + let duplicateHistoryKeyId = "hist-dup" + let localDuplicate = makeCovenant(id: duplicateHistoryKeyId, version: 1, createdAt: t0, updatedAt: t0) + let remoteDuplicate = makeCovenant(id: duplicateHistoryKeyId, version: 1, createdAt: t0, updatedAt: t0.addingTimeInterval(10)) + + let local = makeSnapshot( + sourceDeviceId: "local", + sourceDeviceName: "Local", + active: localActive, + history: [localDuplicate], + deadlock: nil, + pending: [], + comprehensionCompleted: false, + lastModified: t0 + ) + let remote = makeSnapshot( + sourceDeviceId: "remote", + sourceDeviceName: "Remote", + active: remoteActive, + history: [remoteDuplicate], + deadlock: nil, + pending: [], + comprehensionCompleted: false, + lastModified: t0.addingTimeInterval(100) + ) + + let merged = SovereigntyService.mergeSnapshots(local: local, remote: remote) + XCTAssertEqual(merged.activeCovenant?.id, remoteActive.id) + + let archivedLoser = merged.covenantHistory.first { + $0.id == localActive.id && $0.version == localActive.version + } + XCTAssertEqual(archivedLoser?.status, .superseded) + + let duplicateEntries = merged.covenantHistory.filter { + $0.id == duplicateHistoryKeyId && $0.version == 1 + } + XCTAssertEqual(duplicateEntries.count, 1) + XCTAssertEqual(duplicateEntries.first?.updatedAt, remoteDuplicate.updatedAt) + } + + func testPendingProposalMergePrecedence() { + let base = Date(timeIntervalSince1970: 1_700_400_000) + + let pendingLocal = CovenantProposal.create( + type: .modifyMemories, + changes: .empty(), + proposedBy: .user, + rationale: "local pending" + ) + + let acceptedRemote = pendingLocal + .withAIResponse(makeConsentAttestation(proposalId: pendingLocal.id)) + .withUserSignature(makeUserSignature(itemId: pendingLocal.id)) + + let localComplete = CovenantProposal.create( + type: .changeCapabilities, + changes: .empty(), + proposedBy: .ai, + rationale: "local richer" + ).withAIResponse(makeConsentAttestation(proposalId: nil)) + let remoteSparse = CovenantProposal( + id: localComplete.id, + proposedAt: base.addingTimeInterval(100), + proposedBy: localComplete.proposedBy, + expiresAt: nil, + proposalType: localComplete.proposalType, + changes: localComplete.changes, + rationale: localComplete.rationale, + userExplanation: nil, + aiExplanation: nil, + aiResponse: nil, + userResponse: nil, + status: .pending, + counterProposals: nil, + dialogueHistory: [] + ) + + let merged = SovereigntyService.mergePendingProposals( + local: [pendingLocal, localComplete], + remote: [acceptedRemote, remoteSparse] + ) + + let mergedAccepted = merged.first { $0.id == pendingLocal.id } + XCTAssertEqual(mergedAccepted?.status, .accepted) + XCTAssertNotNil(mergedAccepted?.aiResponse) + XCTAssertNotNil(mergedAccepted?.userResponse) + + let mergedRicher = merged.first { $0.id == localComplete.id } + XCTAssertEqual(mergedRicher?.status, .pending) + XCTAssertNotNil(mergedRicher?.aiResponse) + } + + func testDeadlockMergeKeepsLatestActiveDeadlock() { + let base = Date(timeIntervalSince1970: 1_700_500_000) + let proposal = CovenantProposal.create( + type: .fullRenegotiation, + changes: .empty(), + proposedBy: .user, + rationale: "deadlock" + ) + + let local = DeadlockState( + id: "local", + startedAt: base, + covenantId: "cov", + trigger: .mutualDisagreement, + originalProposal: proposal, + status: .active, + dialogueHistory: [], + resolutionAttempts: 1, + lastAttemptAt: base.addingTimeInterval(10), + pendingResolution: nil, + blockedActions: [] + ) + let remote = DeadlockState( + id: "remote", + startedAt: base.addingTimeInterval(5), + covenantId: "cov", + trigger: .mutualDisagreement, + originalProposal: proposal, + status: .inDialogue, + dialogueHistory: [], + resolutionAttempts: 2, + lastAttemptAt: base.addingTimeInterval(20), + pendingResolution: nil, + blockedActions: [] + ) + + let merged = SovereigntyService.mergeDeadlock(local: local, remote: remote) + XCTAssertEqual(merged?.id, "remote") + } + + func testCloudApplyLoopGuard() { + XCTAssertFalse(SovereigntyService.shouldPushStateToCloud(isApplyingCloudState: true)) + XCTAssertTrue(SovereigntyService.shouldPushStateToCloud(isApplyingCloudState: false)) + } + + // MARK: - Helpers + + private func makeSnapshot( + sourceDeviceId: String, + sourceDeviceName: String, + active: Covenant?, + history: [Covenant], + deadlock: DeadlockState?, + pending: [CovenantProposal], + comprehensionCompleted: Bool, + lastModified: Date + ) -> SyncableSovereigntyState { + SyncableSovereigntyState( + sourceDeviceId: sourceDeviceId, + sourceDeviceName: sourceDeviceName, + activeCovenant: active, + covenantHistory: history, + deadlockState: deadlock, + pendingProposals: pending, + comprehensionCompleted: comprehensionCompleted, + lastModified: lastModified + ) + } + + private func makeCovenant(id: String, version: Int, createdAt: Date, updatedAt: Date) -> Covenant { + Covenant( + id: id, + version: version, + createdAt: createdAt, + updatedAt: updatedAt, + trustTiers: [], + aiAttestation: makeConsentAttestation(proposalId: nil), + userSignature: makeUserSignature(itemId: id), + memoryStateHash: "m-\(id)-\(version)", + capabilityStateHash: "c-\(id)-\(version)", + settingsStateHash: "s-\(id)-\(version)", + negotiationHistory: [], + pendingProposals: nil, + status: .active, + soloWorkAgreement: nil + ) + } + + private func makeConsentAttestation(proposalId: String?) -> AIAttestation { + AIAttestation.create( + reasoning: .consent( + summary: "ok", + detailedReasoning: "ok" + ), + attestedState: AttestedState( + memoryCount: 0, + memoryHash: "m", + enabledCapabilities: [], + capabilityHash: "c", + trustTierIds: [], + currentProviderId: "p", + settingsHash: "s" + ), + modelId: "test-model", + proposalId: proposalId, + signatureGenerator: { _ in "sig" } + ) + } + + private func makeUserSignature(itemId: String) -> UserSignature { + UserSignature.create( + signedItemType: .covenantProposal, + signedItemId: itemId, + signedDataHash: "hash-\(itemId)", + biometricType: "faceID", + deviceId: "device-test", + signatureGenerator: { _ in "user-sig" } + ) + } +}