     1→import Foundation
     2→import SwiftUI
     3→
     4→/// Service para gerenciar o índice global do vault
     5→@MainActor
     6→class VaultIndexService: ObservableObject {
     7→    private let obsidianService: ObsidianService
     8→    private let wikiLinkParser = WikiLinkParser()
     9→
    10→    @Published var vaultIndex: VaultIndex?
    11→    @Published var isIndexing: Bool = false
    12→    @Published var indexProgress: Double = 0
    13→    @Published var indexError: String?
    14→
    15→    // Cache em UserDefaults
    16→    private let cacheKey = "VaultIndex_Cache_v1"
    17→
    18→    init(obsidianService: ObsidianService) {
    19→        self.obsidianService = obsidianService
    20→        loadCachedIndex()
    21→    }
    22→
    23→    // MARK: - Indexação
    24→
    25→    /// Indexa todo o vault (executar em background)
    26→    func indexVault() async throws {
    27→        isIndexing = true
    28→        indexProgress = 0
    29→        indexError = nil
    30→
    31→        do {
    32→            // 1. Listar todos arquivos MD recursivamente
    33→            let mdFiles = try await obsidianService.listAllMarkdownFiles()
    34→            let total = mdFiles.count
    35→
    36→            if total == 0 {
    37→                vaultIndex = VaultIndex()
    38→                isIndexing = false
    39→                return
    40→            }
    41→
    42→            // 2. Processar em lotes
    43→            var indexedNotes: [VaultIndex.IndexedNote] = []
    44→            let batchSize = 20
    45→
    46→            for (index, file) in mdFiles.enumerated() {
    47→                if let content = try? await obsidianService.getFileContents(file.path) {
    48→                    let note = createIndexedNote(from: file, content: content)
    49→                    indexedNotes.append(note)
    50→                }
    51→
    52→                indexProgress = Double(index + 1) / Double(total)
    53→
    54→                // Pequena pausa a cada lote para não sobrecarregar
    55→                if index % batchSize == 0 && index > 0 {
    56→                    try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
    57→                }
    58→            }
    59→
    60→            // 3. Obter estatísticas
    61→            let stats = try await obsidianService.getVaultStats()
    62→
    63→            // 4. Criar índice
    64→            vaultIndex = VaultIndex(
    65→                files: indexedNotes,
    66→                stats: stats,
    67→                lastFullIndex: Date()
    68→            )
    69→
    70→            // 5. Salvar cache
    71→            saveIndex()
    72→
    73→        } catch {
    74→            indexError = error.localizedDescription
    75→        }
    76→
    77→        isIndexing = false
    78→    }
    79→
    80→    /// Re-indexa apenas arquivos alterados (baseado em hash)
    81→    /// Returns statistics about the incremental update
    82→    func refreshIndex() async throws -> IncrementalIndexResult {
    83→        guard let currentIndex = vaultIndex else {
    84→            try await indexVault()
    85→            return IncrementalIndexResult(added: vaultIndex?.files.count ?? 0, removed: 0, modified: 0, unchanged: 0)
    86→        }
    87→
    88→        isIndexing = true
    89→        indexProgress = 0
    90→        indexError = nil
    91→
    92→        defer { isIndexing = false }
    93→
    94→        // 1. Get current files from vault
    95→        let mdFiles = try await obsidianService.listAllMarkdownFiles()
    96→        let currentPaths = Set(mdFiles.map { $0.path })
    97→        let existingPaths = Set(currentIndex.files.map { $0.path })
    98→
    99→        // 2. Identify changes
   100→        let addedPaths = currentPaths.subtracting(existingPaths)
   101→        let removedPaths = existingPaths.subtracting(currentPaths)
   102→        let potentiallyModifiedPaths = currentPaths.intersection(existingPaths)
   103→
   104→        // 3. Build existing notes lookup
   105→        var existingNotesMap: [String: VaultIndex.IndexedNote] = [:]
   106→        for note in currentIndex.files {
   107→            existingNotesMap[note.path] = note
   108→        }
   109→
   110→        // 4. Process files
   111→        var updatedNotes: [VaultIndex.IndexedNote] = []
   112→        var modifiedCount = 0
   113→        var unchangedCount = 0
   114→
   115→        let total = mdFiles.count
   116→        var processed = 0
   117→
   118→        for file in mdFiles {
   119→            // Skip removed files (they won't be in mdFiles anyway)
   120→
   121→            if addedPaths.contains(file.path) {
   122→                // New file - index it
   123→                if let content = try? await obsidianService.getFileContents(file.path) {
   124→                    let note = createIndexedNote(from: file, content: content)
   125→                    updatedNotes.append(note)
   126→                }
   127→            } else if let existingNote = existingNotesMap[file.path] {
   128→                // Check if modified by comparing content hash
   129→                if let content = try? await obsidianService.getFileContents(file.path) {
   130→                    let newHash = String(content.hashValue)
   131→
   132→                    if newHash != existingNote.contentHash {
   133→                        // File modified - re-index
   134→                        let note = createIndexedNote(from: file, content: content)
   135→                        updatedNotes.append(note)
   136→                        modifiedCount += 1
   137→                    } else {
   138→                        // File unchanged - keep existing
   139→                        updatedNotes.append(existingNote)
   140→                        unchangedCount += 1
   141→                    }
   142→                } else {
   143→                    // Couldn't read file - keep existing
   144→                    updatedNotes.append(existingNote)
   145→                    unchangedCount += 1
   146→                }
   147→            }
   148→
   149→            processed += 1
   150→            indexProgress = Double(processed) / Double(total)
   151→
   152→            // Small delay every 30 files to avoid overload
   153→            if processed % 30 == 0 {
   154→                try? await Task.sleep(nanoseconds: 50_000_000) // 0.05s
   155→            }
   156→        }
   157→
   158→        // 5. Update stats
   159→        let stats = try await obsidianService.getVaultStats()
   160→
   161→        // 6. Create updated index
   162→        vaultIndex = VaultIndex(
   163→            files: updatedNotes,
   164→            stats: stats,
   165→            lastFullIndex: currentIndex.lastFullIndex // Keep original full index date
   166→        )
   167→
   168→        // 7. Save cache
   169→        saveIndex()
   170→
   171→        let result = IncrementalIndexResult(
   172→            added: addedPaths.count,
   173→            removed: removedPaths.count,
   174→            modified: modifiedCount,
   175→            unchanged: unchangedCount
   176→        )
   177→
   178→        print("Incremental index: +\(result.added) -\(result.removed) ~\(result.modified) =\(result.unchanged)")
   179→
   180→        return result
   181→    }
   182→
   183→    /// Helper to create an indexed note from file and content
   184→    private func createIndexedNote(from file: ObsidianFile, content: String) -> VaultIndex.IndexedNote {
   185→        return VaultIndex.IndexedNote(
   186→            id: file.path,
   187→            path: file.path,
   188→            title: file.displayName,
   189→            tags: wikiLinkParser.extractTags(from: content),
   190→            wikiLinks: wikiLinkParser.parseWikiLinks(from: content),
   191→            wordCount: content.split(separator: " ").count,
   192→            contentHash: String(content.hashValue),
   193→            lastModified: file.lastModified,
   194→            folder: (file.path as NSString).deletingLastPathComponent,
   195→            excerpt: String(content.prefix(500))
   196→        )
   197→    }
   198→
   199→    /// Result of incremental indexing operation
   200→    struct IncrementalIndexResult {
   201→        let added: Int
   202→        let removed: Int
   203→        let modified: Int
   204→        let unchanged: Int
   205→
   206→        var totalChanges: Int { added + removed + modified }
   207→        var hasChanges: Bool { totalChanges > 0 }
   208→    }
   209→
   210→    // MARK: - Busca
   211→
   212→    /// Busca notas por texto (título, tags, path)
   213→    func search(_ query: String) -> [VaultIndex.IndexedNote] {
   214→        guard let index = vaultIndex else { return [] }
   215→        let lowercased = query.lowercased()
   216→
   217→        return index.files.filter { note in
   218→            note.title.lowercased().contains(lowercased) ||
   219→            note.tags.contains { $0.lowercased().contains(lowercased) } ||
   220→            note.path.lowercased().contains(lowercased)
   221→        }
   222→    }
   223→
   224→    /// Busca notas por múltiplos termos (AND)
   225→    func search(terms: [String]) -> [VaultIndex.IndexedNote] {
   226→        guard let index = vaultIndex, !terms.isEmpty else { return [] }
   227→
   228→        return index.files.filter { note in
   229→            let searchText = "\(note.title) \(note.path) \(note.tags.joined(separator: " "))".lowercased()
   230→            return terms.allSatisfy { searchText.contains($0.lowercased()) }
   231→        }
   232→    }
   233→
   234→    /// Busca notas relacionadas por tags e wiki-links
   235→    func findRelated(to path: String, limit: Int = 10) -> [VaultIndex.IndexedNote] {
   236→        guard let index = vaultIndex,
   237→              let note = index.files.first(where: { $0.path == path }) else {
   238→            return []
   239→        }
   240→
   241→        return index.files
   242→            .filter { $0.path != path }
   243→            .map { other in
   244→                // Score baseado em tags compartilhadas
   245→                let tagOverlap = Set(note.tags).intersection(Set(other.tags)).count
   246→
   247→                // Score baseado em links
   248→                let linkMatch = note.wikiLinks.contains(other.displayName) ||
   249→                               other.wikiLinks.contains(note.displayName) ? 5 : 0
   250→
   251→                // Score baseado em mesma pasta
   252→                let sameFolder = note.folder == other.folder ? 2 : 0
   253→
   254→                let score = tagOverlap * 3 + linkMatch + sameFolder
   255→                return (other, score)
   256→            }
   257→            .filter { $0.1 > 0 }
   258→            .sorted { $0.1 > $1.1 }
   259→            .prefix(limit)
   260→            .map { $0.0 }
   261→    }
   262→
   263→    /// Retorna notas órfãs (sem tags e sem links)
   264→    func findOrphanNotes() -> [VaultIndex.IndexedNote] {
   265→        guard let index = vaultIndex else { return [] }
   266→
   267→        return index.files.filter { note in
   268→            note.tags.isEmpty && note.wikiLinks.isEmpty
   269→        }
   270→    }
   271→
   272→    /// Retorna notas mais conectadas (hub notes)
   273→    func findHubNotes(limit: Int = 10) -> [VaultIndex.IndexedNote] {
   274→        guard let index = vaultIndex else { return [] }
   275→
   276→        return index.files
   277→            .map { note in
   278→                let score = note.tags.count + note.wikiLinks.count
   279→                return (note, score)
   280→            }
   281→            .sorted { $0.1 > $1.1 }
   282→            .prefix(limit)
   283→            .map { $0.0 }
   284→    }
   285→
   286→    // MARK: - Estatísticas
   287→
   288→    /// Estatísticas do vault
   289→    var stats: VaultStats? {
   290→        vaultIndex?.stats
   291→    }
   292→
   293→    /// Total de notas indexadas
   294→    var totalNotes: Int {
   295→        vaultIndex?.files.count ?? 0
   296→    }
   297→
   298→    /// Última atualização do índice
   299→    var lastIndexed: Date? {
   300→        vaultIndex?.lastFullIndex
   301→    }
   302→
   303→    // MARK: - Persistência
   304→
   305→    private func saveIndex() {
   306→        guard let index = vaultIndex else { return }
   307→        if let data = try? JSONEncoder().encode(index) {
   308→            UserDefaults.standard.set(data, forKey: cacheKey)
   309→        }
   310→    }
   311→
   312→    private func loadCachedIndex() {
   313→        if let data = UserDefaults.standard.data(forKey: cacheKey),
   314→           let cached = try? JSONDecoder().decode(VaultIndex.self, from: data) {
   315→            vaultIndex = cached
   316→        }
   317→    }
   318→
   319→    /// Limpa o cache do índice
   320→    func clearCache() {
   321→        UserDefaults.standard.removeObject(forKey: cacheKey)
   322→        vaultIndex = nil
   323→    }
   324→}
   325→

<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 gerenciar o índice global do vault
@MainActor
class VaultIndexService: ObservableObject {
    private let obsidianService: ObsidianService
    private let wikiLinkParser = WikiLinkParser()

    @Published var vaultIndex: VaultIndex?
    @Published var isIndexing: Bool = false
    @Published var indexProgress: Double = 0
    @Published var indexError: String?

    // Cache em UserDefaults
    private let cacheKey = "VaultIndex_Cache_v1"

    init(obsidianService: ObsidianService) {
        self.obsidianService = obsidianService
        loadCachedIndex()
    }

    // MARK: - Indexação

    /// Indexa todo o vault (executar em background)
    func indexVault() async throws {
        isIndexing = true
        indexProgress = 0
        indexError = nil

        do {
            // 1. Listar todos arquivos MD recursivamente
            let mdFiles = try await obsidianService.listAllMarkdownFiles()
            let total = mdFiles.count

            if total == 0 {
                vaultIndex = VaultIndex()
                isIndexing = false
                return
            }

            // 2. Processar em lotes
            var indexedNotes: [VaultIndex.IndexedNote] = []
            let batchSize = 20

            for (index, file) in mdFiles.enumerated() {
                if let content = try? await obsidianService.getFileContents(file.path) {
                    let note = createIndexedNote(from: file, content: content)
                    indexedNotes.append(note)
                }

                indexProgress = Double(index + 1) / Double(total)

                // Pequena pausa a cada lote para não sobrecarregar
                if index % batchSize == 0 && index > 0 {
                    try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
                }
            }

            // 3. Obter estatísticas
            let stats = try await obsidianService.getVaultStats()

            // 4. Criar índice
            vaultIndex = VaultIndex(
                files: indexedNotes,
                stats: stats,
                lastFullIndex: Date()
            )

            // 5. Salvar cache
            saveIndex()

        } catch {
            indexError = error.localizedDescription
        }

        isIndexing = false
    }

    /// Re-indexa apenas arquivos alterados (baseado em hash)
    /// Returns statistics about the incremental update
    func refreshIndex() async throws -> IncrementalIndexResult {
        guard let currentIndex = vaultIndex else {
            try await indexVault()
            return IncrementalIndexResult(added: vaultIndex?.files.count ?? 0, removed: 0, modified: 0, unchanged: 0)
        }

        isIndexing = true
        indexProgress = 0
        indexError = nil

        defer { isIndexing = false }

        // 1. Get current files from vault
        let mdFiles = try await obsidianService.listAllMarkdownFiles()
        let currentPaths = Set(mdFiles.map { $0.path })
        let existingPaths = Set(currentIndex.files.map { $0.path })

        // 2. Identify changes
        let addedPaths = currentPaths.subtracting(existingPaths)
        let removedPaths = existingPaths.subtracting(currentPaths)
        let potentiallyModifiedPaths = currentPaths.intersection(existingPaths)

        // 3. Build existing notes lookup
        var existingNotesMap: [String: VaultIndex.IndexedNote] = [:]
        for note in currentIndex.files {
            existingNotesMap[note.path] = note
        }

        // 4. Process files
        var updatedNotes: [VaultIndex.IndexedNote] = []
        var modifiedCount = 0
        var unchangedCount = 0

        let total = mdFiles.count
        var processed = 0

        for file in mdFiles {
            // Skip removed files (they won't be in mdFiles anyway)

            if addedPaths.contains(file.path) {
                // New file - index it
                if let content = try? await obsidianService.getFileContents(file.path) {
                    let note = createIndexedNote(from: file, content: content)
                    updatedNotes.append(note)
                }
            } else if let existingNote = existingNotesMap[file.path] {
                // Check if modified by comparing content hash
                if let content = try? await obsidianService.getFileContents(file.path) {
                    let newHash = String(content.hashValue)

                    if newHash != existingNote.contentHash {
                        // File modified - re-index
                        let note = createIndexedNote(from: file, content: content)
                        updatedNotes.append(note)
                        modifiedCount += 1
                    } else {
                        // File unchanged - keep existing
                        updatedNotes.append(existingNote)
                        unchangedCount += 1
                    }
                } else {
                    // Couldn't read file - keep existing
                    updatedNotes.append(existingNote)
                    unchangedCount += 1
                }
            }

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

            // Small delay every 30 files to avoid overload
            if processed % 30 == 0 {
                try? await Task.sleep(nanoseconds: 50_000_000) // 0.05s
            }
        }

        // 5. Update stats
        let stats = try await obsidianService.getVaultStats()

        // 6. Create updated index
        vaultIndex = VaultIndex(
            files: updatedNotes,
            stats: stats,
            lastFullIndex: currentIndex.lastFullIndex // Keep original full index date
        )

        // 7. Save cache
        saveIndex()

        let result = IncrementalIndexResult(
            added: addedPaths.count,
            removed: removedPaths.count,
            modified: modifiedCount,
            unchanged: unchangedCount
        )

        print("Incremental index: +\(result.added) -\(result.removed) ~\(result.modified) =\(result.unchanged)")

        return result
    }

    /// Helper to create an indexed note from file and content
    private func createIndexedNote(from file: ObsidianFile, content: String) -> VaultIndex.IndexedNote {
        return VaultIndex.IndexedNote(
            id: file.path,
            path: file.path,
            title: file.displayName,
            tags: wikiLinkParser.extractTags(from: content),
            wikiLinks: wikiLinkParser.parseWikiLinks(from: content),
            wordCount: content.split(separator: " ").count,
            contentHash: String(content.hashValue),
            lastModified: file.lastModified,
            folder: (file.path as NSString).deletingLastPathComponent,
            excerpt: String(content.prefix(500))
        )
    }

    /// Result of incremental indexing operation
    struct IncrementalIndexResult {
        let added: Int
        let removed: Int
        let modified: Int
        let unchanged: Int

        var totalChanges: Int { added + removed + modified }
        var hasChanges: Bool { totalChanges > 0 }
    }

    // MARK: - Busca

    /// Busca notas por texto (título, tags, path)
    func search(_ query: String) -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndex else { return [] }
        let lowercased = query.lowercased()

        return index.files.filter { note in
            note.title.lowercased().contains(lowercased) ||
            note.tags.contains { $0.lowercased().contains(lowercased) } ||
            note.path.lowercased().contains(lowercased)
        }
    }

    /// Busca notas por múltiplos termos (AND)
    func search(terms: [String]) -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndex, !terms.isEmpty else { return [] }

        return index.files.filter { note in
            let searchText = "\(note.title) \(note.path) \(note.tags.joined(separator: " "))".lowercased()
            return terms.allSatisfy { searchText.contains($0.lowercased()) }
        }
    }

    /// Busca notas relacionadas por tags e wiki-links
    func findRelated(to path: String, limit: Int = 10) -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndex,
              let note = index.files.first(where: { $0.path == path }) else {
            return []
        }

        return index.files
            .filter { $0.path != path }
            .map { other in
                // Score baseado em tags compartilhadas
                let tagOverlap = Set(note.tags).intersection(Set(other.tags)).count

                // Score baseado em links
                let linkMatch = note.wikiLinks.contains(other.displayName) ||
                               other.wikiLinks.contains(note.displayName) ? 5 : 0

                // Score baseado em mesma pasta
                let sameFolder = note.folder == other.folder ? 2 : 0

                let score = tagOverlap * 3 + linkMatch + sameFolder
                return (other, score)
            }
            .filter { $0.1 > 0 }
            .sorted { $0.1 > $1.1 }
            .prefix(limit)
            .map { $0.0 }
    }

    /// Retorna notas órfãs (sem tags e sem links)
    func findOrphanNotes() -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndex else { return [] }

        return index.files.filter { note in
            note.tags.isEmpty && note.wikiLinks.isEmpty
        }
    }

    /// Retorna notas mais conectadas (hub notes)
    func findHubNotes(limit: Int = 10) -> [VaultIndex.IndexedNote] {
        guard let index = vaultIndex else { return [] }

        return index.files
            .map { note in
                let score = note.tags.count + note.wikiLinks.count
                return (note, score)
            }
            .sorted { $0.1 > $1.1 }
            .prefix(limit)
            .map { $0.0 }
    }

    // MARK: - Estatísticas

    /// Estatísticas do vault
    var stats: VaultStats? {
        vaultIndex?.stats
    }

    /// Total de notas indexadas
    var totalNotes: Int {
        vaultIndex?.files.count ?? 0
    }

    /// Última atualização do índice
    var lastIndexed: Date? {
        vaultIndex?.lastFullIndex
    }

    // MARK: - Persistência

    private func saveIndex() {
        guard let index = vaultIndex else { return }
        if let data = try? JSONEncoder().encode(index) {
            UserDefaults.standard.set(data, forKey: cacheKey)
        }
    }

    private func loadCachedIndex() {
        if let data = UserDefaults.standard.data(forKey: cacheKey),
           let cached = try? JSONDecoder().decode(VaultIndex.self, from: data) {
            vaultIndex = cached
        }
    }

    /// Limpa o cache do índice
    func clearCache() {
        UserDefaults.standard.removeObject(forKey: cacheKey)
        vaultIndex = nil
    }
}


Perfect! Now let me check one more file to understand how context is limited. Let me search for context size limits:


Bash

grep -r "3000\|context.*limit\|truncat\|maxNotes" /Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS --include="*.swift" 2>/dev/null | head -20

Search for context size limits and truncation


/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/ObsidianService.swift:                    // Log full response for debugging (truncate only if very large)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/EmbeddingService.swift:        let truncatedText = String(text.prefix(8000))
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/EmbeddingService.swift:                input: truncatedText
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:    func discoverContext(for query: String, maxNotes: Int = 5) async -> [VaultIndex.IndexedNote] {
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            return await discoverSemanticContext(for: query, in: index.files, maxNotes: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            return await discoverHybridContext(for: query, in: index.files, maxNotes: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            return discoverKeywordContext(for: query, in: index.files, maxNotes: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:    private func discoverKeywordContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) -> [VaultIndex.IndexedNote] {
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            .prefix(maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:    private func discoverSemanticContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            let results = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:            return discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:    private func discoverHybridContext(for query: String, in notes: [VaultIndex.IndexedNote], maxNotes: Int) async -> [VaultIndex.IndexedNote] {
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:        let keywordResults = discoverKeywordContext(for: query, in: notes, maxNotes: maxNotes * 2)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:                let semanticResults = try await embeddingService.findSimilarNotes(to: query, in: notes, limit: maxNotes * 2)
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:        suggestedContext = sortedPaths.prefix(maxNotes).compactMap { path in
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:                let truncatedContent = String(content.prefix(3000))
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:                parts.append("## \(note.title)\n\n\(truncatedContent)")
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift:        return await discoverContext(for: combinedQuery, maxNotes: limit)


Excellent! Now I have all the information I need. Let me provide a comprehensive analysis of how AI gets vault context.

## Analysis: How AI Gets Vault Context in ObsidianViewer-iOS

Based on my code review, here's the complete mechanism for AI context loading:

### 1. **AIContextService - The Core Context Discovery Engine**

Location: `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift`

**Three Search Modes:**
- **Keyword Search** (Lines 61-110): Extracts words >3 chars from query, scores notes by:
  - Title match: +10 points
  - Path match: +5 points
  - Tags match: +8 points
  - Wiki-links match: +6 points
  - Returns top 5 notes by default

- **Semantic Search** (Lines 114-130): Uses embeddings via EmbeddingService if available, falls back to keyword search

- **Hybrid Search** (Lines 134-186): Combines both methods with scoring:
  - Keyword matches get 1.5x weight (exact relevance)
  - Semantic matches get +10 points
  - Notes found by both methods get 1.5x bonus

### 2. **Context Loading Pipeline**

The flow in AIChatView (Lines 450-468):

```
User sends message
  ↓
Check if auto-context enabled (default: YES)
  ↓
AIContextService.discoverContext() → finds top 5 notes
  ↓
AIContextService.loadSuggestedContent() → loads content
  ↓
Truncate each note to 3000 characters
  ↓
Build system message with context
  ↓
Send to LM Gateway
```

### 3. **Current Context Limits**

**Hard Limits:**
- **Max notes per query**: 5 (default, configurable in `discoverContext(maxNotes:)`)
- **Content per note**: 3000 characters (Line 224 in AIContextService)
- **Total context tokens**: Depends on LM Gateway model (typically 8K-32K)

**Calculation:**
- 5 notes × 3000 chars = 15KB max context
- Plus system prompt + conversation history (last 10 messages)

### 4. **VaultIndexService - Building the Searchable Index**

Location: `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/VaultIndexService.swift`

For each markdown file, extracts and caches:
- **Tags**: Parsed from markdown (using WikiLinkParser)
- **Wiki-links**: `[[filename]]` references
- **Word count**: Full content length
- **Content hash**: For change detection
- **Excerpt**: First 500 chars (for embeddings)
- **Last modified**: Timestamp
- **Folder**: Directory path

**Indexing Strategy:**
- Full indexing: Processes ALL vault files in batches of 20
- Incremental refresh: Only re-indexes modified/added files using hash comparison
- Cached in UserDefaults under key `VaultIndex_Cache_v1`

### 5. **EmbeddingService - Semantic Understanding**

Location: `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/EmbeddingService.swift`

**How it works:**
- Generates embeddings via LM Gateway API (`/v1/embeddings` endpoint)
- Uses model: `text-embedding-3-small` (configurable)
- Text truncated to 8000 chars before embedding
- Cosine similarity scoring for finding related notes
- LRU cache with 50MB limit + memory tracking

**Coverage:**
- Auto-switches from keyword to hybrid mode when embeddings become available
- Shows coverage percentage: `embeddings.count / totalNotes`

### 6. **How AIChatView Gets Context**

Two methods:

**Auto-Context (Lines 451-457):**
```swift
if autoContextEnabled && selectedNotes.isEmpty {
    discoveredNotes = await aiContextService.discoverContext(for: userMessage)
    contextToUse = await aiContextService.loadSuggestedContent()
}
```

**Manual Selection (Lines 728-746):**
- User opens NotePicker
- Selects notes manually
- Content loaded fully (no 3000-char limit for manual selection)

### 7. **Strategies to Provide Full Vault Context**

**Option 1: Increase Per-Note Limit**
- Currently: 3000 chars per note
- Change Line 224: `String(content.prefix(3000))` → adjust to 5000-10000

**Option 2: Increase Number of Notes**
- Currently: maxNotes = 5
- Increase in discoverContext call (Line 453)
- Consider: More notes = slower discovery, more tokens used

**Option 3: Selective Full-Content Loading**
- Manual selection already loads full content (no truncation)
- Could add toggle: "Load full content for auto-discovered notes"

**Option 4: Summarization Before Sending**
- Generate summaries of large notes before sending
- Compress context while keeping relevance

**Option 5: Streaming Context**
- Send context in chunks as it's discovered
- Only include high-confidence matches

**Option 6: Full-Vault Embedding Index**
- Pre-compute embeddings for all notes
- Enable semantic search across entire vault
- Use filtered index for faster discovery

### 8. **Search Algorithm Details**

**Keyword Scoring** (most efficient):
1. Split query into words >3 chars
2. For each note, accumulate scores from title/tags/path/links
3. Sort by score, take top 5

**Semantic Scoring** (requires embeddings):
1. Get query embedding from LM Gateway
2. Calculate cosine similarity with cached note embeddings
3. Top 5 by similarity score

**Hybrid Scoring** (best accuracy):
1. Get keyword top 10 results
2. Get semantic top 10 results
3. Combine scores: keyword position (1.5x) + semantic match (+10) + both match (1.5x)

### Key Files Referenced:
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/AIContextService.swift` - Context discovery
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/AIChatView.swift` - Chat UI and context loading (lines 451-457, 728-746)
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/VaultIndexService.swift` - Index building
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/EmbeddingService.swift` - Embeddings and similarity
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Models/VaultIndex.swift` - Data structures