Search for how AI gets vault context:
1. How does AIContextService work?
2. How does the AI chat get context from vault?
3. What's the current context limit?
4. How could we provide full vault context?

Look in AIContextService.swift, AIChatView.swift, EmbeddingService.swift. Return the mechanisms for context loading.


I'll search through the iOS codebase to understand how AI gets vault context. Let me start by exploring the relevant files.


Now let me search for the specific files mentioned:


Bash

find /Users/neog/Apps/iOS -name "AIContextService.swift" -o -name "AIChatView.swift" -o -name "EmbeddingService.swift" 2>/dev/null

Find AI context and embedding service files


/Users/neog/Apps/iOS/KortexOS/KortexOS/Views/AI/AIChatView.swift
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/AIChatView.swift


Perfect! Now let me read these files to understand the context mechanisms:


     1→import Foundation
     2→import SwiftUI
     3→
     4→/// Service para auto-descoberta de contexto relevante para AI
     5→/// Supports both keyword-based and semantic (embedding) search
     6→@MainActor
     7→class AIContextService: ObservableObject {
     8→    private let obsidianService: ObsidianService
     9→    private let vaultIndexService: VaultIndexService
    10→    private let lmService: LMGatewayService
    11→    private var embeddingService: EmbeddingService?
    12→
    13→    @Published var suggestedContext: [VaultIndex.IndexedNote] = []
    14→    @Published var loadedContextContent: String = ""
    15→    @Published var isDiscovering: Bool = false
    16→    @Published var searchMode: SearchMode = .keyword
    17→
    18→    enum SearchMode: String, CaseIterable {
    19→        case keyword = "Keyword"
    20→        case semantic = "Semantic"
    21→        case hybrid = "Hybrid"
    22→    }
    23→
    24→    init(obsidianService: ObsidianService,
    25→         vaultIndexService: VaultIndexService,
    26→         lmService: LMGatewayService,
    27→         embeddingService: EmbeddingService? = nil) {
    28→        self.obsidianService = obsidianService
    29→        self.vaultIndexService = vaultIndexService
    30→        self.lmService = lmService
    31→        self.embeddingService = embeddingService
    32→    }
    33→
    34→    /// Configure embedding service for semantic search
    35→    func setEmbeddingService(_ service: EmbeddingService) {
    36→        self.embeddingService = service
    37→    }
    38→
    39→    // MARK: - Context Discovery
    40→
    41→    /// Descobre notas relevantes para uma pergunta/query
    42→    /// Supports keyword, semantic (embedding), and hybrid search modes
    43→    func discoverContext(for query: String, maxNotes: Int = 5) async -> [VaultIndex.IndexedNote] {
    44→        guard let index = vaultIndexService.vaultIndex else { return [] }
    45→
    46→        isDiscovering = true
    47→        defer { isDiscovering = false }
    48→
    49→        switch searchMode {
    50→        case .semantic:
    51→            return await discoverSemanticContext(for: query, in: index.files, maxNotes: maxNotes)
    52→        case .hybrid:
    53→            return await discoverHybridContext(for: query, in: index.files, maxNotes: maxNotes)
    54→        case .keyword:
    55→            return discoverKeywordContext(for: query, in: index.files, maxNotes: maxNotes)
    56→        }
    57→    }
    58→
    59→    // MARK: - Keyword-based Search
    60→
    61→    private func discoverKeywordContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) -> [VaultIndex.IndexedNote] {
    62→        // 1. Extrair keywords da query (palavras com mais de 3 caracteres)
    63→        let keywords = query.lowercased()
    64→            .components(separatedBy: CharacterSet.alphanumerics.inverted)
    65→            .filter { $0.count > 3 }
    66→
    67→        // 2. Pontuar cada nota
    68→        var scored: [(VaultIndex.IndexedNote, Int)] = []
    69→
    70→        for note in notes {
    71→            var score = 0
    72→            let titleLower = note.title.lowercased()
    73→            let pathLower = note.path.lowercased()
    74→            let tagsLower = note.tags.map { $0.lowercased() }
    75→
    76→            for keyword in keywords {
    77→                // Match em título (peso alto)
    78→                if titleLower.contains(keyword) {
    79→                    score += 10
    80→                }
    81→
    82→                // Match em path (peso médio)
    83→                if pathLower.contains(keyword) {
    84→                    score += 5
    85→                }
    86→
    87→                // Match em tags (peso alto)
    88→                if tagsLower.contains(where: { $0.contains(keyword) }) {
    89→                    score += 8
    90→                }
    91→
    92→                // Match em wiki-links (peso médio)
    93→                if note.wikiLinks.contains(where: { $0.lowercased().contains(keyword) }) {
    94→                    score += 6
    95→                }
    96→            }
    97→
    98→            if score > 0 {
    99→                scored.append((note, score))
   100→            }
   101→        }
   102→
   103→        // 3. Ordenar por score e pegar os top N
   104→        suggestedContext = scored
   105→            .sorted { $0.1 > $1.1 }
   106→            .prefix(maxNotes)
   107→            .map { $0.0 }
   108→
   109→        return suggestedContext
   110→    }
   111→
   112→    // MARK: - Semantic Search (Embeddings)
   113→
   114→    private func discoverSemanticContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
   115→        guard let embeddingService = embeddingService,
   116→              embeddingService.hasEmbeddings else {
   117→            // Fallback to keyword search if embeddings not available
   118→            print("Embeddings not available, falling back to keyword search")
   119→            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
   120→        }
   121→
   122→        do {
   123→            let results = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes)
   124→            suggestedContext = results.map { $0.note }
   125→            return suggestedContext
   126→        } catch {
   127→            print("Semantic search failed: \(error), falling back to keyword search")
   128→            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
   129→        }
   130→    }
   131→
   132→    // MARK: - Hybrid Search (Combines Keyword + Semantic)
   133→
   134→    private func discoverHybridContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
   135→        // Get keyword results
   136→        let keywordResults = discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes * 2)
   137→        let keywordPaths = Set(keywordResults.map { $0.path })
   138→
   139→        // Get semantic results if available
   140→        var semanticPaths: Set<String> = []
   141→        if let embeddingService = embeddingService, embeddingService.hasEmbeddings {
   142→            do {
   143→                let semanticResults = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes * 2)
   144→                semanticPaths = Set(semanticResults.map { $0.note.path })
   145→            } catch {
   146→                print("Semantic search in hybrid mode failed: \(error)")
   147→            }
   148→        }
   149→
   150→        // Combine: prioritize notes found by both methods
   151→        var combinedScores: [String: Double] = [:]
   152→
   153→        for note in notes {
   154→            var score: Double = 0
   155→
   156→            if keywordPaths.contains(note.path) {
   157→                // Higher weight for keyword matches (exact relevance)
   158→                if let idx = keywordResults.firstIndex(where: { $0.path == note.path }) {
   159→                    score += Double(keywordResults.count - idx) * 1.5
   160→                }
   161→            }
   162→
   163→            if semanticPaths.contains(note.path) {
   164→                // Semantic match bonus
   165→                score += 10
   166→            }
   167→
   168→            // Bonus for appearing in both
   169→            if keywordPaths.contains(note.path) && semanticPaths.contains(note.path) {
   170→                score *= 1.5
   171→            }
   172→
   173→            if score > 0 {
   174→                combinedScores[note.path] = score
   175→            }
   176→        }
   177→
   178→        // Sort by combined score
   179→        let sortedPaths = combinedScores.sorted { $0.value > $1.value }.map { $0.key }
   180→
   181→        suggestedContext = sortedPaths.prefix(maxNotes).compactMap { path in
   182→            notes.first { $0.path == path }
   183→        }
   184→
   185→        return suggestedContext
   186→    }
   187→
   188→    // MARK: - Embedding Management
   189→
   190→    /// Generate embeddings for all indexed notes
   191→    func generateEmbeddings() async {
   192→        guard let embeddingService = embeddingService,
   193→              let index = vaultIndexService.vaultIndex else { return }
   194→
   195→        do {
   196→            try await embeddingService.generateEmbeddings(for: index.files)
   197→            // Auto-switch to hybrid mode when embeddings become available
   198→            if embeddingService.hasEmbeddings && searchMode == .keyword {
   199→                searchMode = .hybrid
   200→            }
   201→        } catch {
   202→            print("Failed to generate embeddings: \(error)")
   203→        }
   204→    }
   205→
   206→    /// Check if semantic search is available
   207→    var semanticSearchAvailable: Bool {
   208→        embeddingService?.hasEmbeddings ?? false
   209→    }
   210→
   211→    /// Get embedding coverage percentage
   212→    var embeddingCoverage: Double {
   213→        guard let index = vaultIndexService.vaultIndex else { return 0 }
   214→        return embeddingService?.getCoverage(totalNotes: index.files.count) ?? 0
   215→    }
   216→
   217→    /// Carrega o conteúdo das notas sugeridas
   218→    func loadSuggestedContent() async -> String {
   219→        var parts: [String] = []
   220→
   221→        for note in suggestedContext {
   222→            if let content = try? await obsidianService.getFileContents(note.path) {
   223→                // Limitar tamanho para evitar contexto muito grande
   224→                let truncatedContent = String(content.prefix(3000))
   225→                parts.append("## \(note.title)\n\n\(truncatedContent)")
   226→            }
   227→        }
   228→
   229→        loadedContextContent = parts.joined(separator: "\n\n---\n\n")
   230→        return loadedContextContent
   231→    }
   232→
   233→    /// Constrói contexto completo para AI (query + notas relevantes)
   234→    func buildAIContext(for query: String) async -> String {
   235→        _ = await discoverContext(for: query)
   236→        let content = await loadSuggestedContent()
   237→
   238→        if content.isEmpty {
   239→            return "User question: \(query)"
   240→        }
   241→
   242→        return """
   243→        Context from relevant notes in user's Obsidian vault:
   244→
   245→        \(content)
   246→
   247→        ---
   248→
   249→        User question: \(query)
   250→
   251→        Instructions: Answer based on the context provided above when relevant. If the context doesn't contain relevant information, you can still provide a helpful general answer.
   252→        """
   253→    }
   254→
   255→    // MARK: - Related Notes
   256→
   257→    /// Encontra notas relacionadas a uma nota específica
   258→    func findRelatedNotes(for path: String, limit: Int = 5) async -> [VaultIndex.IndexedNote] {
   259→        let related = vaultIndexService.findRelated(to: path, limit: limit)
   260→        suggestedContext = related
   261→        return related
   262→    }
   263→
   264→    // MARK: - Context Suggestions
   265→
   266→    /// Sugere notas baseado no histórico de conversa
   267→    func suggestFromConversation(messages: [String], limit: Int = 5) async -> [VaultIndex.IndexedNote] {
   268→        // Combinar todas as mensagens em uma query
   269→        let combinedQuery = messages.suffix(5).joined(separator: " ")
   270→        return await discoverContext(for: combinedQuery, maxNotes: limit)
   271→    }
   272→
   273→    // MARK: - Utilities
   274→
   275→    /// Limpa o contexto atual
   276→    func clearContext() {
   277→        suggestedContext = []
   278→        loadedContextContent = ""
   279→    }
   280→
   281→    /// Verifica se há contexto disponível
   282→    var hasContext: Bool {
   283→        !suggestedContext.isEmpty
   284→    }
   285→
   286→    /// Número de notas no contexto
   287→    var contextCount: Int {
   288→        suggestedContext.count
   289→    }
   290→}
   291→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import SwiftUI

/// Service para auto-descoberta de contexto relevante para AI
/// Supports both keyword-based and semantic (embedding) search
@MainActor
class AIContextService: ObservableObject {
    private let obsidianService: ObsidianService
    private let vaultIndexService: VaultIndexService
    private let lmService: LMGatewayService
    private var embeddingService: EmbeddingService?

    @Published var suggestedContext: [VaultIndex.IndexedNote] = []
    @Published var loadedContextContent: String = ""
    @Published var isDiscovering: Bool = false
    @Published var searchMode: SearchMode = .keyword

    enum SearchMode: String, CaseIterable {
        case keyword = "Keyword"
        case semantic = "Semantic"
        case hybrid = "Hybrid"
    }

    init(obsidianService: ObsidianService,
         vaultIndexService: VaultIndexService,
         lmService: LMGatewayService,
         embeddingService: EmbeddingService? = nil) {
        self.obsidianService = obsidianService
        self.vaultIndexService = vaultIndexService
        self.lmService = lmService
        self.embeddingService = embeddingService
    }

    /// Configure embedding service for semantic search
    func setEmbeddingService(_ service: EmbeddingService) {
        self.embeddingService = service
    }

    // MARK: - Context Discovery

    /// Descobre notas relevantes para uma pergunta/query
    /// Supports keyword, semantic (embedding), and hybrid search modes
    func discoverContext(for query: String, maxNotes: Int = 5) async -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndexService.vaultIndex else { return [] }

        isDiscovering = true
        defer { isDiscovering = false }

        switch searchMode {
        case .semantic:
            return await discoverSemanticContext(for: query, in: index.files, maxNotes: maxNotes)
        case .hybrid:
            return await discoverHybridContext(for: query, in: index.files, maxNotes: maxNotes)
        case .keyword:
            return discoverKeywordContext(for: query, in: index.files, maxNotes: maxNotes)
        }
    }

    // MARK: - Keyword-based Search

    private func discoverKeywordContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) -> [VaultIndex.IndexedNote] {
        // 1. Extrair keywords da query (palavras com mais de 3 caracteres)
        let keywords = query.lowercased()
            .components(separatedBy: CharacterSet.alphanumerics.inverted)
            .filter { $0.count > 3 }

        // 2. Pontuar cada nota
        var scored: [(VaultIndex.IndexedNote, Int)] = []

        for note in notes {
            var score = 0
            let titleLower = note.title.lowercased()
            let pathLower = note.path.lowercased()
            let tagsLower = note.tags.map { $0.lowercased() }

            for keyword in keywords {
                // Match em título (peso alto)
                if titleLower.contains(keyword) {
                    score += 10
                }

                // Match em path (peso médio)
                if pathLower.contains(keyword) {
                    score += 5
                }

                // Match em tags (peso alto)
                if tagsLower.contains(where: { $0.contains(keyword) }) {
                    score += 8
                }

                // Match em wiki-links (peso médio)
                if note.wikiLinks.contains(where: { $0.lowercased().contains(keyword) }) {
                    score += 6
                }
            }

            if score > 0 {
                scored.append((note, score))
            }
        }

        // 3. Ordenar por score e pegar os top N
        suggestedContext = scored
            .sorted { $0.1 > $1.1 }
            .prefix(maxNotes)
            .map { $0.0 }

        return suggestedContext
    }

    // MARK: - Semantic Search (Embeddings)

    private func discoverSemanticContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
        guard let embeddingService = embeddingService,
              embeddingService.hasEmbeddings else {
            // Fallback to keyword search if embeddings not available
            print("Embeddings not available, falling back to keyword search")
            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
        }

        do {
            let results = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes)
            suggestedContext = results.map { $0.note }
            return suggestedContext
        } catch {
            print("Semantic search failed: \(error), falling back to keyword search")
            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
        }
    }

    // MARK: - Hybrid Search (Combines Keyword + Semantic)

    private func discoverHybridContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
        // Get keyword results
        let keywordResults = discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes * 2)
        let keywordPaths = Set(keywordResults.map { $0.path })

        // Get semantic results if available
        var semanticPaths: Set<String> = []
        if let embeddingService = embeddingService, embeddingService.hasEmbeddings {
            do {
                let semanticResults = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes * 2)
                semanticPaths = Set(semanticResults.map { $0.note.path })
            } catch {
                print("Semantic search in hybrid mode failed: \(error)")
            }
        }

        // Combine: prioritize notes found by both methods
        var combinedScores: [String: Double] = [:]

        for note in notes {
            var score: Double = 0

            if keywordPaths.contains(note.path) {
                // Higher weight for keyword matches (exact relevance)
                if let idx = keywordResults.firstIndex(where: { $0.path == note.path }) {
                    score += Double(keywordResults.count - idx) * 1.5
                }
            }

            if semanticPaths.contains(note.path) {
                // Semantic match bonus
                score += 10
            }

            // Bonus for appearing in both
            if keywordPaths.contains(note.path) && semanticPaths.contains(note.path) {
                score *= 1.5
            }

            if score > 0 {
                combinedScores[note.path] = score
            }
        }

        // Sort by combined score
        let sortedPaths = combinedScores.sorted { $0.value > $1.value }.map { $0.key }

        suggestedContext = sortedPaths.prefix(maxNotes).compactMap { path in
            notes.first { $0.path == path }
        }

        return suggestedContext
    }

    // MARK: - Embedding Management

    /// Generate embeddings for all indexed notes
    func generateEmbeddings() async {
        guard let embeddingService = embeddingService,
              let index = vaultIndexService.vaultIndex else { return }

        do {
            try await embeddingService.generateEmbeddings(for: index.files)
            // Auto-switch to hybrid mode when embeddings become available
            if embeddingService.hasEmbeddings && searchMode == .keyword {
                searchMode = .hybrid
            }
        } catch {
            print("Failed to generate embeddings: \(error)")
        }
    }

    /// Check if semantic search is available
    var semanticSearchAvailable: Bool {
        embeddingService?.hasEmbeddings ?? false
    }

    /// Get embedding coverage percentage
    var embeddingCoverage: Double {
        guard let index = vaultIndexService.vaultIndex else { return 0 }
        return embeddingService?.getCoverage(totalNotes: index.files.count) ?? 0
    }

    /// Carrega o conteúdo das notas sugeridas
    func loadSuggestedContent() async -> String {
        var parts: [String] = []

        for note in suggestedContext {
            if let content = try? await obsidianService.getFileContents(note.path) {
                // Limitar tamanho para evitar contexto muito grande
                let truncatedContent = String(content.prefix(3000))
                parts.append("## \(note.title)\n\n\(truncatedContent)")
            }
        }

        loadedContextContent = parts.joined(separator: "\n\n---\n\n")
        return loadedContextContent
    }

    /// Constrói contexto completo para AI (query + notas relevantes)
    func buildAIContext(for query: String) async -> String {
        _ = await discoverContext(for: query)
        let content = await loadSuggestedContent()

        if content.isEmpty {
            return "User question: \(query)"
        }

        return """
        Context from relevant notes in user's Obsidian vault:

        \(content)

        ---

        User question: \(query)

        Instructions: Answer based on the context provided above when relevant. If the context doesn't contain relevant information, you can still provide a helpful general answer.
        """
    }

    // MARK: - Related Notes

    /// Encontra notas relacionadas a uma nota específica
    func findRelatedNotes(for path: String, limit: Int = 5) async -> [VaultIndex.IndexedNote] {
        let related = vaultIndexService.findRelated(to: path, limit: limit)
        suggestedContext = related
        return related
    }

    // MARK: - Context Suggestions

    /// Sugere notas baseado no histórico de conversa
    func suggestFromConversation(messages: [String], limit: Int = 5) async -> [VaultIndex.IndexedNote] {
        // Combinar todas as mensagens em uma query
        let combinedQuery = messages.suffix(5).joined(separator: " ")
        return await discoverContext(for: combinedQuery, maxNotes: limit)
    }

    // MARK: - Utilities

    /// Limpa o contexto atual
    func clearContext() {
        suggestedContext = []
        loadedContextContent = ""
    }

    /// Verifica se há contexto disponível
    var hasContext: Bool {
        !suggestedContext.isEmpty
    }

    /// Número de notas no contexto
    var contextCount: Int {
        suggestedContext.count
    }
}


     1→import Foundation
     2→
     3→/// Service for generating and managing text embeddings for semantic search
     4→@MainActor
     5→class EmbeddingService: ObservableObject {
     6→
     7→    // MARK: - Configuration
     8→
     9→    private var baseURL: String
    10→    private var apiKey: String
    11→    private let embeddingModel: String
    12→
    13→    // MARK: - Retry Configuration
    14→
    15→    private let retryPolicy = RetryPolicy.default
    16→
    17→    // MARK: - Published State
    18→
    19→    @Published var embeddings: [String: [Float]] = [:]  // path -> embedding vector
    20→    @Published var isGenerating: Bool = false
    21→    @Published var generationProgress: Double = 0
    22→    @Published var errorMessage: String?
    23→
    24→    // MARK: - Persistence
    25→
    26→    private let cacheFile = "embeddings_cache.json"
    27→
    28→    // MARK: - Init
    29→
    30→    /// Initialize with credentials from Keychain or provided values
    31→    /// Note: Credentials should be configured via Keychain, not hardcoded
    32→    init(
    33→        baseURL: String? = nil,
    34→        apiKey: String? = nil,
    35→        embeddingModel: String = "text-embedding-3-small"
    36→    ) {
    37→        self.baseURL = baseURL ?? Keychain.lmGatewayUrl ?? ""
    38→        self.apiKey = apiKey ?? Keychain.lmGatewayApiKey ?? ""
    39→        self.embeddingModel = embeddingModel
    40→        loadCachedEmbeddings()
    41→    }
    42→
    43→    /// Check if service is properly configured with credentials
    44→    var isConfigured: Bool {
    45→        !baseURL.isEmpty && !apiKey.isEmpty
    46→    }
    47→
    48→    func configure(baseURL: String, apiKey: String, saveToKeychain: Bool = true) {
    49→        self.baseURL = baseURL
    50→        self.apiKey = apiKey
    51→
    52→        if saveToKeychain {
    53→            Keychain.lmGatewayUrl = baseURL
    54→            Keychain.lmGatewayApiKey = apiKey
    55→        }
    56→    }
    57→
    58→    // MARK: - Generate Single Embedding
    59→
    60→    /// Generate embedding vector for a text
    61→    func getEmbedding(for text: String) async throws -> [Float] {
    62→        guard let url = URL(string: "\(baseURL)/v1/embeddings") else {
    63→            throw EmbeddingError.invalidURL
    64→        }
    65→
    66→        // Limit text to 8000 chars for embedding models
    67→        let truncatedText = String(text.prefix(8000))
    68→
    69→        return try await Retry.execute(policy: retryPolicy, operation: "getEmbedding") {
    70→            var request = URLRequest(url: url)
    71→            request.httpMethod = "POST"
    72→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
    73→            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    74→            request.timeoutInterval = 60
    75→
    76→            let body = EmbeddingRequest(
    77→                model: self.embeddingModel,
    78→                input: truncatedText
    79→            )
    80→
    81→            request.httpBody = try JSONEncoder().encode(body)
    82→
    83→            let (data, response) = try await URLSession.shared.data(for: request)
    84→
    85→            guard let httpResponse = response as? HTTPURLResponse else {
    86→                throw EmbeddingError.invalidResponse
    87→            }
    88→
    89→            guard httpResponse.statusCode == 200 else {
    90→                Log.warning("Embedding HTTP \(httpResponse.statusCode)", category: .network)
    91→                if let errorText = String(data: data, encoding: .utf8) {
    92→                    Log.debug("Embedding error response: \(errorText.prefix(500))", category: .network)
    93→                    throw EmbeddingError.apiError(errorText)
    94→                }
    95→                throw EmbeddingError.httpError(httpResponse.statusCode)
    96→            }
    97→
    98→            // Debug: log raw response for troubleshooting
    99→            if let rawResponse = String(data: data, encoding: .utf8) {
   100→                Log.debug("Embedding raw response: \(rawResponse.prefix(300))...", category: .network)
   101→            }
   102→
   103→            // Try standard OpenAI format first
   104→            do {
   105→                let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: data)
   106→                if let embedding = embeddingResponse.data.first?.embedding {
   107→                    return embedding
   108→                }
   109→            } catch {
   110→                Log.debug("Standard embedding parse failed: \(error.localizedDescription)", category: .network)
   111→            }
   112→
   113→            // Try alternative format (some APIs use different structure)
   114→            do {
   115→                let altResponse = try JSONDecoder().decode(AlternativeEmbeddingResponse.self, from: data)
   116→                if let embedding = altResponse.firstEmbedding {
   117→                    return embedding
   118→                }
   119→            } catch {
   120→                Log.debug("Alternative embedding parse failed: \(error.localizedDescription)", category: .network)
   121→            }
   122→
   123→            // If all parsing fails, throw with more detail
   124→            if let rawResponse = String(data: data, encoding: .utf8) {
   125→                Log.error("Failed to parse embedding response: \(rawResponse.prefix(500))", category: .network)
   126→            }
   127→            throw EmbeddingError.noEmbedding
   128→        }
   129→    }
   130→
   131→    // MARK: - Batch Embedding Generation
   132→
   133→    /// Generate embeddings for multiple notes
   134→    func generateEmbeddings(for notes: [VaultIndex.IndexedNote], forceRegenerate: Bool = false) async throws {
   135→        guard !isGenerating else { return }
   136→
   137→        isGenerating = true
   138→        generationProgress = 0
   139→        errorMessage = nil
   140→
   141→        defer { isGenerating = false }
   142→
   143→        let notesToProcess = forceRegenerate
   144→            ? notes
   145→            : notes.filter { embeddings[$0.path] == nil }
   146→
   147→        guard !notesToProcess.isEmpty else {
   148→            Log.info("All embeddings already cached (\(embeddings.count) total)", category: .performance)
   149→            return
   150→        }
   151→
   152→        let total = notesToProcess.count
   153→        var processed = 0
   154→        var failed = 0
   155→
   156→        Log.info("Starting embedding generation for \(total) notes", category: .network)
   157→        let startTime = Date()
   158→
   159→        for note in notesToProcess {
   160→            do {
   161→                // Use title + excerpt for embedding
   162→                let textForEmbedding = "\(note.title)\n\n\(note.excerpt)"
   163→                let embedding = try await getEmbedding(for: textForEmbedding)
   164→
   165→                // Use LRU-tracked storage
   166→                storeEmbedding(embedding, for: note.path)
   167→
   168→                processed += 1
   169→                generationProgress = Double(processed) / Double(total)
   170→
   171→                // Log progress every 50 notes
   172→                if processed % 50 == 0 {
   173→                    Log.debug("Embeddings progress: \(processed)/\(total)", category: .network)
   174→                }
   175→
   176→                // Small delay to avoid rate limiting
   177→                try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
   178→
   179→            } catch {
   180→                failed += 1
   181→                Log.warning("Failed to generate embedding for \(note.path): \(error.localizedDescription)", category: .network)
   182→                // Continue with other notes
   183→            }
   184→        }
   185→
   186→        // Save cache after generation
   187→        saveCachedEmbeddings()
   188→
   189→        let elapsed = Date().timeIntervalSince(startTime)
   190→        Log.info("Embedding generation complete: \(processed) succeeded, \(failed) failed in \(String(format: "%.1f", elapsed))s", category: .network)
   191→    }
   192→
   193→    // MARK: - Similarity Search
   194→
   195→    /// Calculate cosine similarity between two vectors
   196→    func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
   197→        guard a.count == b.count, !a.isEmpty else { return 0 }
   198→
   199→        var dotProduct: Float = 0
   200→        var normA: Float = 0
   201→        var normB: Float = 0
   202→
   203→        for i in 0..<a.count {
   204→            dotProduct += a[i] * b[i]
   205→            normA += a[i] * a[i]
   206→            normB += b[i] * b[i]
   207→        }
   208→
   209→        let denominator = sqrt(normA) * sqrt(normB)
   210→        guard denominator > 0 else { return 0 }
   211→
   212→        return dotProduct / denominator
   213→    }
   214→
   215→    /// Find notes most similar to a query
   216→    func findSimilarNotes(
   217→        to query: String,
   218→        in notes: [VaultIndex.IndexedNote],
   219→        limit: Int = 5
   220→    ) async throws -> [(note: VaultIndex.IndexedNote, similarity: Float)] {
   221→
   222→        // Generate embedding for query
   223→        let queryEmbedding = try await getEmbedding(for: query)
   224→
   225→        var results: [(note: VaultIndex.IndexedNote, similarity: Float)] = []
   226→
   227→        for note in notes {
   228→            guard let noteEmbedding = embeddings[note.path] else { continue }
   229→
   230→            let similarity = cosineSimilarity(queryEmbedding, noteEmbedding)
   231→            results.append((note: note, similarity: similarity))
   232→        }
   233→
   234→        // Sort by similarity (highest first) and take top N
   235→        return results
   236→            .sorted { $0.similarity > $1.similarity }
   237→            .prefix(limit)
   238→            .map { $0 }
   239→    }
   240→
   241→    /// Find notes similar to a specific note
   242→    func findRelatedNotes(
   243→        to notePath: String,
   244→        in notes: [VaultIndex.IndexedNote],
   245→        limit: Int = 5
   246→    ) -> [(note: VaultIndex.IndexedNote, similarity: Float)] {
   247→
   248→        guard let sourceEmbedding = embeddings[notePath] else { return [] }
   249→
   250→        var results: [(note: VaultIndex.IndexedNote, similarity: Float)] = []
   251→
   252→        for note in notes where note.path != notePath {
   253→            guard let noteEmbedding = embeddings[note.path] else { continue }
   254→
   255→            let similarity = cosineSimilarity(sourceEmbedding, noteEmbedding)
   256→            results.append((note: note, similarity: similarity))
   257→        }
   258→
   259→        return results
   260→            .sorted { $0.similarity > $1.similarity }
   261→            .prefix(limit)
   262→            .map { $0 }
   263→    }
   264→
   265→    // MARK: - Cache Management (LRU with Memory Limit)
   266→
   267→    /// Maximum cache size in bytes (default 50MB)
   268→    private let maxCacheSizeBytes: Int = 50 * 1024 * 1024
   269→
   270→    /// Estimated bytes per embedding (1536 floats * 4 bytes + path string overhead)
   271→    private let estimatedBytesPerEmbedding: Int = 1536 * 4 + 200
   272→
   273→    /// LRU tracking: path -> last access timestamp
   274→    private var accessTimestamps: [String: Date] = [:]
   275→
   276→    private var cacheURL: URL? {
   277→        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
   278→            .appendingPathComponent(cacheFile)
   279→    }
   280→
   281→    private var timestampsCacheURL: URL? {
   282→        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
   283→            .appendingPathComponent("embeddings_timestamps.json")
   284→    }
   285→
   286→    /// Estimated current cache size in bytes
   287→    var estimatedCacheSizeBytes: Int {
   288→        embeddings.count * estimatedBytesPerEmbedding
   289→    }
   290→
   291→    /// Estimated cache size in MB (for display)
   292→    var estimatedCacheSizeMB: Double {
   293→        Double(estimatedCacheSizeBytes) / (1024 * 1024)
   294→    }
   295→
   296→    private func loadCachedEmbeddings() {
   297→        // Load embeddings
   298→        guard let url = cacheURL,
   299→              let data = try? Data(contentsOf: url),
   300→              let cached = try? JSONDecoder().decode([String: [Float]].self, from: data) else {
   301→            return
   302→        }
   303→        embeddings = cached
   304→
   305→        // Load timestamps
   306→        if let timestampsURL = timestampsCacheURL,
   307→           let timestampsData = try? Data(contentsOf: timestampsURL),
   308→           let timestamps = try? JSONDecoder().decode([String: Date].self, from: timestampsData) {
   309→            accessTimestamps = timestamps
   310→        } else {
   311→            // Initialize timestamps for existing embeddings
   312→            let now = Date()
   313→            for path in embeddings.keys {
   314→                accessTimestamps[path] = now
   315→            }
   316→        }
   317→
   318→        Log.info("Loaded \(embeddings.count) cached embeddings (~\(String(format: "%.1f", estimatedCacheSizeMB))MB)", category: .performance)
   319→    }
   320→
   321→    private func saveCachedEmbeddings() {
   322→        // Save embeddings
   323→        guard let url = cacheURL,
   324→              let data = try? JSONEncoder().encode(embeddings) else {
   325→            return
   326→        }
   327→        try? data.write(to: url)
   328→
   329→        // Save timestamps
   330→        if let timestampsURL = timestampsCacheURL,
   331→           let timestampsData = try? JSONEncoder().encode(accessTimestamps) {
   332→            try? timestampsData.write(to: timestampsURL)
   333→        }
   334→
   335→        Log.debug("Saved \(embeddings.count) embeddings to cache (~\(String(format: "%.1f", estimatedCacheSizeMB))MB)", category: .performance)
   336→    }
   337→
   338→    /// Record access for LRU tracking
   339→    private func recordAccess(for path: String) {
   340→        accessTimestamps[path] = Date()
   341→    }
   342→
   343→    /// Evict least recently used embeddings if cache exceeds limit
   344→    private func evictIfNeeded() {
   345→        guard estimatedCacheSizeBytes > maxCacheSizeBytes else { return }
   346→
   347→        let targetSize = maxCacheSizeBytes * 80 / 100  // Evict to 80% of limit
   348→
   349→        // Sort by access time (oldest first)
   350→        let sortedPaths = accessTimestamps
   351→            .sorted { $0.value < $1.value }
   352→            .map { $0.key }
   353→
   354→        var currentSize = estimatedCacheSizeBytes
   355→        var evictedCount = 0
   356→
   357→        for path in sortedPaths {
   358→            guard currentSize > targetSize else { break }
   359→
   360→            embeddings.removeValue(forKey: path)
   361→            accessTimestamps.removeValue(forKey: path)
   362→            currentSize -= estimatedBytesPerEmbedding
   363→            evictedCount += 1
   364→        }
   365→
   366→        if evictedCount > 0 {
   367→            Log.info("LRU cache eviction: removed \(evictedCount) embeddings, now ~\(String(format: "%.1f", estimatedCacheSizeMB))MB", category: .performance)
   368→            saveCachedEmbeddings()
   369→        }
   370→    }
   371→
   372→    /// Get embedding with LRU tracking
   373→    func getEmbeddingWithTracking(for path: String) -> [Float]? {
   374→        guard let embedding = embeddings[path] else { return nil }
   375→        recordAccess(for: path)
   376→        return embedding
   377→    }
   378→
   379→    /// Store embedding with LRU tracking and eviction check
   380→    func storeEmbedding(_ embedding: [Float], for path: String) {
   381→        embeddings[path] = embedding
   382→        recordAccess(for: path)
   383→        evictIfNeeded()
   384→    }
   385→
   386→    /// Clear all cached embeddings
   387→    func clearCache() {
   388→        embeddings.removeAll()
   389→        accessTimestamps.removeAll()
   390→        if let url = cacheURL {
   391→            try? FileManager.default.removeItem(at: url)
   392→        }
   393→        if let timestampsURL = timestampsCacheURL {
   394→            try? FileManager.default.removeItem(at: timestampsURL)
   395→        }
   396→        Log.info("Embeddings cache cleared", category: .performance)
   397→    }
   398→
   399→    /// Check if embeddings are available for semantic search
   400→    var hasEmbeddings: Bool {
   401→        !embeddings.isEmpty
   402→    }
   403→
   404→    /// Get coverage percentage of indexed notes
   405→    func getCoverage(totalNotes: Int) -> Double {
   406→        guard totalNotes > 0 else { return 0 }
   407→        return Double(embeddings.count) / Double(totalNotes)
   408→    }
   409→
   410→    /// Get cache statistics
   411→    var cacheStats: (count: Int, sizeMB: Double, maxSizeMB: Double) {
   412→        (embeddings.count, estimatedCacheSizeMB, Double(maxCacheSizeBytes) / (1024 * 1024))
   413→    }
   414→}
   415→
   416→// MARK: - Request/Response Models
   417→
   418→struct EmbeddingRequest: Codable {
   419→    let model: String
   420→    let input: String
   421→}
   422→
   423→struct EmbeddingResponse: Codable {
   424→    let data: [EmbeddingData]
   425→    let model: String?
   426→    let usage: EmbeddingUsage?
   427→
   428→    struct EmbeddingData: Codable {
   429→        let embedding: [Float]
   430→        let index: Int?  // Made optional for compatibility
   431→
   432→        // Custom decoding to handle different formats
   433→        init(from decoder: Decoder) throws {
   434→            let container = try decoder.container(keyedBy: CodingKeys.self)
   435→            self.embedding = try container.decode([Float].self, forKey: .embedding)
   436→            self.index = try container.decodeIfPresent(Int.self, forKey: .index)
   437→        }
   438→
   439→        enum CodingKeys: String, CodingKey {
   440→            case embedding, index
   441→        }
   442→    }
   443→
   444→    struct EmbeddingUsage: Codable {
   445→        let prompt_tokens: Int?
   446→        let total_tokens: Int?
   447→    }
   448→}
   449→
   450→/// Alternative embedding response format for different APIs
   451→struct AlternativeEmbeddingResponse: Codable {
   452→    let embedding: [Float]?      // Direct embedding
   453→    let embeddings: [[Float]]?   // Array of embeddings
   454→    let vector: [Float]?         // Some APIs use "vector"
   455→    let data: [[Float]]?         // Raw data array
   456→
   457→    var firstEmbedding: [Float]? {
   458→        embedding ?? embeddings?.first ?? vector ?? data?.first
   459→    }
   460→}
   461→
   462→// MARK: - Errors
   463→
   464→enum EmbeddingError: LocalizedError {
   465→    case invalidURL
   466→    case invalidResponse
   467→    case httpError(Int)
   468→    case noEmbedding
   469→    case apiError(String)
   470→
   471→    var errorDescription: String? {
   472→        switch self {
   473→        case .invalidURL:
   474→            return "Invalid embedding service URL"
   475→        case .invalidResponse:
   476→            return "Invalid response from embedding service"
   477→        case .httpError(let code):
   478→            return "Embedding service error: HTTP \(code)"
   479→        case .noEmbedding:
   480→            return "No embedding returned from service"
   481→        case .apiError(let message):
   482→            return "Embedding API error: \(message)"
   483→        }
   484→    }
   485→}
   486→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation

/// Service for generating and managing text embeddings for semantic search
@MainActor
class EmbeddingService: ObservableObject {

    // MARK: - Configuration

    private var baseURL: String
    private var apiKey: String
    private let embeddingModel: String

    // MARK: - Retry Configuration

    private let retryPolicy = RetryPolicy.default

    // MARK: - Published State

    @Published var embeddings: [String: [Float]] = [:]  // path -> embedding vector
    @Published var isGenerating: Bool = false
    @Published var generationProgress: Double = 0
    @Published var errorMessage: String?

    // MARK: - Persistence

    private let cacheFile = "embeddings_cache.json"

    // MARK: - Init

    /// Initialize with credentials from Keychain or provided values
    /// Note: Credentials should be configured via Keychain, not hardcoded
    init(
        baseURL: String? = nil,
        apiKey: String? = nil,
        embeddingModel: String = "text-embedding-3-small"
    ) {
        self.baseURL = baseURL ?? Keychain.lmGatewayUrl ?? ""
        self.apiKey = apiKey ?? Keychain.lmGatewayApiKey ?? ""
        self.embeddingModel = embeddingModel
        loadCachedEmbeddings()
    }

    /// Check if service is properly configured with credentials
    var isConfigured: Bool {
        !baseURL.isEmpty && !apiKey.isEmpty
    }

    func configure(baseURL: String, apiKey: String, saveToKeychain: Bool = true) {
        self.baseURL = baseURL
        self.apiKey = apiKey

        if saveToKeychain {
            Keychain.lmGatewayUrl = baseURL
            Keychain.lmGatewayApiKey = apiKey
        }
    }

    // MARK: - Generate Single Embedding

    /// Generate embedding vector for a text
    func getEmbedding(for text: String) async throws -> [Float] {
        guard let url = URL(string: "\(baseURL)/v1/embeddings") else {
            throw EmbeddingError.invalidURL
        }

        // Limit text to 8000 chars for embedding models
        let truncatedText = String(text.prefix(8000))

        return try await Retry.execute(policy: retryPolicy, operation: "getEmbedding") {
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.timeoutInterval = 60

            let body = EmbeddingRequest(
                model: self.embeddingModel,
                input: truncatedText
            )

            request.httpBody = try JSONEncoder().encode(body)

            let (data, response) = try await URLSession.shared.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw EmbeddingError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                Log.warning("Embedding HTTP \(httpResponse.statusCode)", category: .network)
                if let errorText = String(data: data, encoding: .utf8) {
                    Log.debug("Embedding error response: \(errorText.prefix(500))", category: .network)
                    throw EmbeddingError.apiError(errorText)
                }
                throw EmbeddingError.httpError(httpResponse.statusCode)
            }

            // Debug: log raw response for troubleshooting
            if let rawResponse = String(data: data, encoding: .utf8) {
                Log.debug("Embedding raw response: \(rawResponse.prefix(300))...", category: .network)
            }

            // Try standard OpenAI format first
            do {
                let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: data)
                if let embedding = embeddingResponse.data.first?.embedding {
                    return embedding
                }
            } catch {
                Log.debug("Standard embedding parse failed: \(error.localizedDescription)", category: .network)
            }

            // Try alternative format (some APIs use different structure)
            do {
                let altResponse = try JSONDecoder().decode(AlternativeEmbeddingResponse.self, from: data)
                if let embedding = altResponse.firstEmbedding {
                    return embedding
                }
            } catch {
                Log.debug("Alternative embedding parse failed: \(error.localizedDescription)", category: .network)
            }

            // If all parsing fails, throw with more detail
            if let rawResponse = String(data: data, encoding: .utf8) {
                Log.error("Failed to parse embedding response: \(rawResponse.prefix(500))", category: .network)
            }
            throw EmbeddingError.noEmbedding
        }
    }

    // MARK: - Batch Embedding Generation

    /// Generate embeddings for multiple notes
    func generateEmbeddings(for notes: [VaultIndex.IndexedNote], forceRegenerate: Bool = false) async throws {
        guard !isGenerating else { return }

        isGenerating = true
        generationProgress = 0
        errorMessage = nil

        defer { isGenerating = false }

        let notesToProcess = forceRegenerate
            ? notes
            : notes.filter { embeddings[$0.path] == nil }

        guard !notesToProcess.isEmpty else {
            Log.info("All embeddings already cached (\(embeddings.count) total)", category: .performance)
            return
        }

        let total = notesToProcess.count
        var processed = 0
        var failed = 0

        Log.info("Starting embedding generation for \(total) notes", category: .network)
        let startTime = Date()

        for note in notesToProcess {
            do {
                // Use title + excerpt for embedding
                let textForEmbedding = "\(note.title)\n\n\(note.excerpt)"
                let embedding = try await getEmbedding(for: textForEmbedding)

                // Use LRU-tracked storage
                storeEmbedding(embedding, for: note.path)

                processed += 1
                generationProgress = Double(processed) / Double(total)

                // Log progress every 50 notes
                if processed % 50 == 0 {
                    Log.debug("Embeddings progress: \(processed)/\(total)", category: .network)
                }

                // Small delay to avoid rate limiting
                try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds

            } catch {
                failed += 1
                Log.warning("Failed to generate embedding for \(note.path): \(error.localizedDescription)", category: .network)
                // Continue with other notes
            }
        }

        // Save cache after generation
        saveCachedEmbeddings()

        let elapsed = Date().timeIntervalSince(startTime)
        Log.info("Embedding generation complete: \(processed) succeeded, \(failed) failed in \(String(format: "%.1f", elapsed))s", category: .network)
    }

    // MARK: - Similarity Search

    /// Calculate cosine similarity between two vectors
    func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
        guard a.count == b.count, !a.isEmpty else { return 0 }

        var dotProduct: Float = 0
        var normA: Float = 0
        var normB: Float = 0

        for i in 0..<a.count {
            dotProduct += a[i] * b[i]
            normA += a[i] * a[i]
            normB += b[i] * b[i]
        }

        let denominator = sqrt(normA) * sqrt(normB)
        guard denominator > 0 else { return 0 }

        return dotProduct / denominator
    }

    /// Find notes most similar to a query
    func findSimilarNotes(
        to query: String,
        in notes: [VaultIndex.IndexedNote],
        limit: Int = 5
    ) async throws -> [(note: VaultIndex.IndexedNote, similarity: Float)] {

        // Generate embedding for query
        let queryEmbedding = try await getEmbedding(for: query)

        var results: [(note: VaultIndex.IndexedNote, similarity: Float)] = []

        for note in notes {
            guard let noteEmbedding = embeddings[note.path] else { continue }

            let similarity = cosineSimilarity(queryEmbedding, noteEmbedding)
            results.append((note: note, similarity: similarity))
        }

        // Sort by similarity (highest first) and take top N
        return results
            .sorted { $0.similarity > $1.similarity }
            .prefix(limit)
            .map { $0 }
    }

    /// Find notes similar to a specific note
    func findRelatedNotes(
        to notePath: String,
        in notes: [VaultIndex.IndexedNote],
        limit: Int = 5
    ) -> [(note: VaultIndex.IndexedNote, similarity: Float)] {

        guard let sourceEmbedding = embeddings[notePath] else { return [] }

        var results: [(note: VaultIndex.IndexedNote, similarity: Float)] = []

        for note in notes where note.path != notePath {
            guard let noteEmbedding = embeddings[note.path] else { continue }

            let similarity = cosineSimilarity(sourceEmbedding, noteEmbedding)
            results.append((note: note, similarity: similarity))
        }

        return results
            .sorted { $0.similarity > $1.similarity }
            .prefix(limit)
            .map { $0 }
    }

    // MARK: - Cache Management (LRU with Memory Limit)

    /// Maximum cache size in bytes (default 50MB)
    private let maxCacheSizeBytes: Int = 50 * 1024 * 1024

    /// Estimated bytes per embedding (1536 floats * 4 bytes + path string overhead)
    private let estimatedBytesPerEmbedding: Int = 1536 * 4 + 200

    /// LRU tracking: path -> last access timestamp
    private var accessTimestamps: [String: Date] = [:]

    private var cacheURL: URL? {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
            .appendingPathComponent(cacheFile)
    }

    private var timestampsCacheURL: URL? {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
            .appendingPathComponent("embeddings_timestamps.json")
    }

    /// Estimated current cache size in bytes
    var estimatedCacheSizeBytes: Int {
        embeddings.count * estimatedBytesPerEmbedding
    }

    /// Estimated cache size in MB (for display)
    var estimatedCacheSizeMB: Double {
        Double(estimatedCacheSizeBytes) / (1024 * 1024)
    }

    private func loadCachedEmbeddings() {
        // Load embeddings
        guard let url = cacheURL,
              let data = try? Data(contentsOf: url),
              let cached = try? JSONDecoder().decode([String: [Float]].self, from: data) else {
            return
        }
        embeddings = cached

        // Load timestamps
        if let timestampsURL = timestampsCacheURL,
           let timestampsData = try? Data(contentsOf: timestampsURL),
           let timestamps = try? JSONDecoder().decode([String: Date].self, from: timestampsData) {
            accessTimestamps = timestamps
        } else {
            // Initialize timestamps for existing embeddings
            let now = Date()
            for path in embeddings.keys {
                accessTimestamps[path] = now
            }
        }

        Log.info("Loaded \(embeddings.count) cached embeddings (~\(String(format: "%.1f", estimatedCacheSizeMB))MB)", category: .performance)
    }

    private func saveCachedEmbeddings() {
        // Save embeddings
        guard let url = cacheURL,
              let data = try? JSONEncoder().encode(embeddings) else {
            return
        }
        try? data.write(to: url)

        // Save timestamps
        if let timestampsURL = timestampsCacheURL,
           let timestampsData = try? JSONEncoder().encode(accessTimestamps) {
            try? timestampsData.write(to: timestampsURL)
        }

        Log.debug("Saved \(embeddings.count) embeddings to cache (~\(String(format: "%.1f", estimatedCacheSizeMB))MB)", category: .performance)
    }

    /// Record access for LRU tracking
    private func recordAccess(for path: String) {
        accessTimestamps[path] = Date()
    }

    /// Evict least recently used embeddings if cache exceeds limit
    private func evictIfNeeded() {
        guard estimatedCacheSizeBytes > maxCacheSizeBytes else { return }

        let targetSize = maxCacheSizeBytes * 80 / 100  // Evict to 80% of limit

        // Sort by access time (oldest first)
        let sortedPaths = accessTimestamps
            .sorted { $0.value < $1.value }
            .map { $0.key }

        var currentSize = estimatedCacheSizeBytes
        var evictedCount = 0

        for path in sortedPaths {
            guard currentSize > targetSize else { break }

            embeddings.removeValue(forKey: path)
            accessTimestamps.removeValue(forKey: path)
            currentSize -= estimatedBytesPerEmbedding
            evictedCount += 1
        }

        if evictedCount > 0 {
            Log.info("LRU cache eviction: removed \(evictedCount) embeddings, now ~\(String(format: "%.1f", estimatedCacheSizeMB))MB", category: .performance)
            saveCachedEmbeddings()
        }
    }

    /// Get embedding with LRU tracking
    func getEmbeddingWithTracking(for path: String) -> [Float]? {
        guard let embedding = embeddings[path] else { return nil }
        recordAccess(for: path)
        return embedding
    }

    /// Store embedding with LRU tracking and eviction check
    func storeEmbedding(_ embedding: [Float], for path: String) {
        embeddings[path] = embedding
        recordAccess(for: path)
        evictIfNeeded()
    }

    /// Clear all cached embeddings
    func clearCache() {
        embeddings.removeAll()
        accessTimestamps.removeAll()
        if let url = cacheURL {
            try? FileManager.default.removeItem(at: url)
        }
        if let timestampsURL = timestampsCacheURL {
            try? FileManager.default.removeItem(at: timestampsURL)
        }
        Log.info("Embeddings cache cleared", category: .performance)
    }

    /// Check if embeddings are available for semantic search
    var hasEmbeddings: Bool {
        !embeddings.isEmpty
    }

    /// Get coverage percentage of indexed notes
    func getCoverage(totalNotes: Int) -> Double {
        guard totalNotes > 0 else { return 0 }
        return Double(embeddings.count) / Double(totalNotes)
    }

    /// Get cache statistics
    var cacheStats: (count: Int, sizeMB: Double, maxSizeMB: Double) {
        (embeddings.count, estimatedCacheSizeMB, Double(maxCacheSizeBytes) / (1024 * 1024))
    }
}

// MARK: - Request/Response Models

struct EmbeddingRequest: Codable {
    let model: String
    let input: String
}

struct EmbeddingResponse: Codable {
    let data: [EmbeddingData]
    let model: String?
    let usage: EmbeddingUsage?

    struct EmbeddingData: Codable {
        let embedding: [Float]
        let index: Int?  // Made optional for compatibility

        // Custom decoding to handle different formats
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            self.embedding = try container.decode([Float].self, forKey: .embedding)
            self.index = try container.decodeIfPresent(Int.self, forKey: .index)
        }

        enum CodingKeys: String, CodingKey {
            case embedding, index
        }
    }

    struct EmbeddingUsage: Codable {
        let prompt_tokens: Int?
        let total_tokens: Int?
    }
}

/// Alternative embedding response format for different APIs
struct AlternativeEmbeddingResponse: Codable {
    let embedding: [Float]?      // Direct embedding
    let embeddings: [[Float]]?   // Array of embeddings
    let vector: [Float]?         // Some APIs use "vector"
    let data: [[Float]]?         // Raw data array

    var firstEmbedding: [Float]? {
        embedding ?? embeddings?.first ?? vector ?? data?.first
    }
}

// MARK: - Errors

enum EmbeddingError: LocalizedError {
    case invalidURL
    case invalidResponse
    case httpError(Int)
    case noEmbedding
    case apiError(String)

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid embedding service URL"
        case .invalidResponse:
            return "Invalid response from embedding service"
        case .httpError(let code):
            return "Embedding service error: HTTP \(code)"
        case .noEmbedding:
            return "No embedding returned from service"
        case .apiError(let message):
            return "Embedding API error: \(message)"
        }
    }
}