Explore the SmartConnectionService.swift to understand:

1. The current `AnalysisProgress` struct - what fields it has and how it's used
2. How batches are processed in `analyzeVaultConnectionsParallel`
3. What status messages and progress updates are currently sent to the UI
4. The current phase structure (Phase 1, 2, 3, etc.)

Focus on understanding the current progress tracking mechanism and what information is available during processing.

Return:
- The AnalysisProgress struct definition
- Key methods that update progress
- Current status messages being used
- Batch processing logic


I'll explore the SmartConnectionService.swift file to understand the current progress tracking mechanism and batch processing logic.


Now let me read the file to examine the AnalysisProgress struct and progress tracking mechanisms:


     1→import Foundation
     2→import SwiftUI
     3→import Combine
     4→
     5→/// Core AI Agent service for Smart Connections
     6→/// Orchestrates vault analysis, connection discovery, and feedback loop learning
     7→@MainActor
     8→class SmartConnectionService: ObservableObject {
     9→
    10→    // MARK: - Dependencies
    11→
    12→    private let obsidianService: ObsidianService
    13→    private let lmService: LMGatewayService
    14→    private let multiAgent: DualAgentProcessor
    15→    private let persistence: ConnectionPersistence
    16→    private let wikiLinkParser: WikiLinkParser
    17→
    18→    // MARK: - Published State
    19→
    20→    @Published var vaultGraph: VaultGraph?
    21→    @Published var pendingSuggestions: [ConnectionSuggestion] = []
    22→    @Published var analysisProgress: AnalysisProgress?
    23→    @Published var feedbackMetrics: FeedbackMetrics
    24→    @Published var learningWeights: LearningWeights
    25→    @Published var isAnalyzing: Bool = false
    26→    @Published var lastError: String?
    27→
    28→    // MARK: - Configuration
    29→
    30→    @AppStorage("smartConnections_batchSize") var batchSize: Int = 10
    31→    @AppStorage("smartConnections_minimumStrength") var minimumStrengthThreshold: Double = 0.5
    32→    @AppStorage("smartConnections_maxSuggestionsPerNote") var maxSuggestionsPerNote: Int = 5
    33→    @AppStorage("smartConnections_autoWriteConnections") var autoWriteConnections: Bool = true
    34→    @AppStorage("smartConnections_useMultiAgent") var useMultiAgent: Bool = true
    35→
    36→    // MARK: - Init
    37→
    38→    init(
    39→        obsidianService: ObsidianService,
    40→        lmService: LMGatewayService,
    41→        multiAgent: DualAgentProcessor = .shared,
    42→        persistence: ConnectionPersistence = ConnectionPersistence(),
    43→        wikiLinkParser: WikiLinkParser = WikiLinkParser()
    44→    ) {
    45→        self.obsidianService = obsidianService
    46→        self.lmService = lmService
    47→        self.multiAgent = multiAgent
    48→        self.persistence = persistence
    49→        self.wikiLinkParser = wikiLinkParser
    50→        self.feedbackMetrics = persistence.loadMetrics()
    51→        self.learningWeights = persistence.loadLearningWeights()
    52→    }
    53→
    54→    // MARK: - Load Persisted Data
    55→
    56→    func loadPersistedData() {
    57→        vaultGraph = persistence.loadGraph()
    58→        pendingSuggestions = persistence.loadSuggestions().filter { $0.status == .pending }
    59→        feedbackMetrics = persistence.loadMetrics()
    60→        learningWeights = persistence.loadLearningWeights()
    61→    }
    62→
    63→    // MARK: - Full Vault Analysis Pipeline
    64→
    65→    /// Full vault analysis (initial or refresh)
    66→    func analyzeVault() async throws {
    67→        guard !isAnalyzing else {
    68→            Log.warning("Analysis already in progress, skipping", category: .vault)
    69→            return
    70→        }
    71→
    72→        Log.info("Starting vault analysis...", category: .vault)
    73→        isAnalyzing = true
    74→        lastError = nil
    75→
    76→        // Initialize progress with all new fields
    77→        var progress = AnalysisProgress(
    78→            totalNotes: 0,
    79→            processedNotes: 0,
    80→            currentPhase: .idle,
    81→            startedAt: Date()
    82→        )
    83→        progress.startPhase(.loadingFiles)
    84→        analysisProgress = progress
    85→
    86→        defer {
    87→            isAnalyzing = false
    88→            if let elapsed = analysisProgress?.elapsedTime {
    89→                Log.info("Analysis completed in \(String(format: "%.1f", elapsed))s", category: .vault)
    90→            }
    91→        }
    92→
    93→        do {
    94→            // Phase 1: Load all files RECURSIVELY (includes nested folders)
    95→            Log.debug("Phase 1: Loading files from vault...", category: .vault)
    96→            analysisProgress?.statusMessage = "Loading files from vault..."
    97→            let markdownFiles = try await obsidianService.listAllMarkdownFiles()
    98→            analysisProgress?.totalNotes = markdownFiles.count
    99→            analysisProgress?.statusMessage = "Found \(markdownFiles.count) notes"
   100→            Log.info("Found \(markdownFiles.count) markdown files", category: .vault)
   101→
   102→            // Phase 2: Load content in BATCHES and parse wiki-links (memory optimized)
   103→            Log.debug("Phase 2: Parsing wiki-links and metadata (batch mode)...", category: .vault)
   104→            analysisProgress?.startPhase(.parsingWikiLinks)
   105→            analysisProgress?.statusMessage = "Parsing wiki-links and metadata..."
   106→            var nodes: [String: NoteNode] = [:]
   107→            var existingConnections: [NoteConnection] = []
   108→            let parsingStartTime = Date()
   109→
   110→            // Process files in batches to reduce memory footprint
   111→            let fileBatchSize = 20  // Load 20 files at a time
   112→            let fileBatches = markdownFiles.chunked(into: fileBatchSize)
   113→            var allNotePaths: [String] = []  // Just paths, not content
   114→            var noteExcerpts: [(path: String, excerpt: String)] = []  // Truncated content for AI
   115→
   116→            // UI update batching: only update every N files to reduce jank
   117→            let uiUpdateInterval = 10
   118→            var localProcessedCount = 0
   119→            var localConnectionCount = 0
   120→
   121→            for (batchIndex, fileBatch) in fileBatches.enumerated() {
   122→                let batchPaths = fileBatch.map { $0.path }
   123→
   124→                // Update status only once per batch (not per file)
   125→                if batchIndex % 3 == 0 {  // Even less frequent status updates
   126→                    analysisProgress?.statusMessage = "Loading batch \(batchIndex + 1)/\(fileBatches.count)..."
   127→                }
   128→
   129→                // Parallel batch loading
   130→                let batchContents = try await obsidianService.getBatchFileContents(batchPaths)
   131→
   132→                // Process each file in batch, then release full content
   133→                for (fileIndex, file) in fileBatch.enumerated() {
   134→                    guard let content = batchContents[file.path] else { continue }
   135→
   136→                    // Only update currentNotePath every N files to reduce UI updates
   137→                    if localProcessedCount % uiUpdateInterval == 0 {
   138→                        analysisProgress?.currentNotePath = file.path
   139→                    }
   140→
   141→                    // Parse all metadata in one pass (using existing parser)
   142→                    let wikiLinks = wikiLinkParser.parseWikiLinks(from: content)
   143→                    let tags = wikiLinkParser.extractTags(from: content)
   144→                    let metadata = wikiLinkParser.parseMetadata(from: content)
   145→                    let hash = wikiLinkParser.contentHash(for: content)
   146→                    let title = wikiLinkParser.extractTitle(from: content) ?? file.displayName
   147→
   148→                    // Store only path and truncated excerpt for AI analysis (max 2000 chars)
   149→                    let excerpt = String(content.prefix(2000))
   150→                    noteExcerpts.append((file.path, excerpt))
   151→                    allNotePaths.append(file.path)
   152→
   153→                    // Create node (metadata only, no full content)
   154→                    nodes[file.path] = NoteNode(
   155→                        path: file.path,
   156→                        title: title,
   157→                        incomingConnections: 0,
   158→                        outgoingConnections: wikiLinks.count,
   159→                        clusterIds: [],
   160→                        isOrphan: wikiLinks.isEmpty,
   161→                        lastAnalyzed: Date(),
   162→                        contentHash: hash,
   163→                        tags: tags,
   164→                        metadata: metadata
   165→                    )
   166→
   167→                    // Create wiki-link connections
   168→                    for link in wikiLinks {
   169→                        existingConnections.append(NoteConnection(
   170→                            sourceNotePath: file.path,
   171→                            targetNotePath: link,
   172→                            connectionType: .wikiLink,
   173→                            strength: 1.0,
   174→                            reason: "Explicit wiki-link in source note",
   175→                            isUserAccepted: true,
   176→                            keywords: []
   177→                        ))
   178→                    }
   179→
   180→                    localProcessedCount += 1
   181→                    localConnectionCount += wikiLinks.count
   182→
   183→                    // Batch UI updates: only update @Published property every N files
   184→                    if localProcessedCount % uiUpdateInterval == 0 {
   185→                        analysisProgress?.processedNotes = localProcessedCount
   186→                        analysisProgress?.totalConnectionsFound = localConnectionCount
   187→                    }
   188→                }
   189→                // batchContents is released here when it goes out of scope
   190→
   191→                // Update throughput every batch (not per file)
   192→                let elapsed = Date().timeIntervalSince(parsingStartTime)
   193→                analysisProgress?.updateThroughput(notesProcessed: localProcessedCount, timeInterval: elapsed)
   194→            }
   195→
   196→            // Final sync of progress after all batches
   197→            analysisProgress?.processedNotes = localProcessedCount
   198→            analysisProgress?.totalConnectionsFound = localConnectionCount
   199→
   200→            // Final throughput update for parsing phase
   201→            let parsingElapsed = Date().timeIntervalSince(parsingStartTime)
   202→            analysisProgress?.updateThroughput(notesProcessed: markdownFiles.count, timeInterval: parsingElapsed)
   203→            Log.info("Phase 2 complete: parsed \(nodes.count) notes, found \(existingConnections.count) wiki-links in \(String(format: "%.1f", parsingElapsed))s (memory optimized)", category: .vault)
   204→
   205→            // Phase 3: AI semantic analysis (batched) - using excerpts, not full content
   206→            Log.debug("Phase 3: Starting AI semantic analysis...", category: .ai)
   207→            analysisProgress?.startPhase(.analyzingContent)
   208→            analysisProgress?.processedNotes = 0
   209→            let aiConnections = try await analyzeContentInBatches(
   210→                notes: noteExcerpts,  // Using truncated excerpts (max 2000 chars each)
   211→                allNotePaths: allNotePaths,  // Pass paths separately to avoid recomputing
   212→                existingConnections: existingConnections
   213→            )
   214→
   215→            // Phase 4: Build graph
   216→            Log.debug("Phase 4: Building knowledge graph...", category: .vault)
   217→            analysisProgress?.startPhase(.buildingGraph)
   218→            analysisProgress?.statusMessage = "Building knowledge graph..."
   219→            updateIncomingCounts(nodes: &nodes, connections: existingConnections + aiConnections)
   220→            Log.debug("Graph built with \(nodes.count) nodes, \(existingConnections.count + aiConnections.count) connections", category: .vault)
   221→
   222→            // Phase 5: Identify clusters
   223→            Log.debug("Phase 5: Identifying topic clusters...", category: .vault)
   224→            analysisProgress?.startPhase(.identifyingClusters)
   225→            analysisProgress?.statusMessage = "Identifying topic clusters..."
   226→            let clusters = identifyClusters(nodes: Array(nodes.values), connections: existingConnections)
   227→            Log.info("Identified \(clusters.count) topic clusters", category: .vault)
   228→
   229→            // Phase 6: Generate suggestions
   230→            Log.debug("Phase 6: Generating connection suggestions...", category: .ai)
   231→            analysisProgress?.startPhase(.generatingSuggestions)
   232→            analysisProgress?.statusMessage = "Generating connection suggestions..."
   233→            let suggestions = aiConnections
   234→                .filter { !$0.isUserAccepted && !$0.isUserRejected && $0.strength >= minimumStrengthThreshold }
   235→                .map { ConnectionSuggestion(suggestedConnection: $0, batchId: UUID()) }
   236→
   237→            // Update state
   238→            vaultGraph = VaultGraph(
   239→                nodes: nodes,
   240→                edges: existingConnections,
   241→                clusters: clusters,
   242→                lastFullAnalysis: Date(),
   243→                totalNotes: markdownFiles.count,
   244→                orphanCount: nodes.values.filter { $0.isOrphan }.count,
   245→                connectionCount: existingConnections.count
   246→            )
   247→
   248→            pendingSuggestions = suggestions
   249→            feedbackMetrics.totalSuggestionsGenerated += suggestions.count
   250→
   251→            // Persist
   252→            Log.debug("Saving analysis results...", category: .vault)
   253→            try persistence.saveGraph(vaultGraph!)
   254→            try persistence.saveSuggestions(suggestions)
   255→            try persistence.saveMetrics(feedbackMetrics)
   256→            Log.info("Analysis results saved successfully", category: .vault)
   257→
   258→            // Mark complete with final status
   259→            analysisProgress?.markComplete()
   260→            analysisProgress?.statusMessage = "Found \(suggestions.count) new suggestions"
   261→
   262→            // Final summary log
   263→            Log.info("Analysis complete: \(nodes.count) notes, \(existingConnections.count) wiki-links, \(aiConnections.count) AI connections, \(suggestions.count) suggestions, \(clusters.count) clusters", category: .vault)
   264→
   265→        } catch {
   266→            Log.error("Analysis failed: \(error.localizedDescription)", category: .vault)
   267→            lastError = error.localizedDescription
   268→            analysisProgress?.markFailed(error: error.localizedDescription)
   269→            throw error
   270→        }
   271→    }
   272→
   273→    // MARK: - Batch Content Analysis (Parallel Processing)
   274→
   275→    /// Maximum concurrent API requests (to respect rate limits)
   276→    private let maxConcurrentBatches = 2
   277→
   278→    private func analyzeContentInBatches(
   279→        notes: [(path: String, excerpt: String)],
   280→        allNotePaths: [String],
   281→        existingConnections: [NoteConnection]
   282→    ) async throws -> [NoteConnection] {
   283→        let analysisStartTime = Date()
   284→
   285→        // Create batches
   286→        let batches = notes.chunked(into: batchSize)
   287→        analysisProgress?.totalBatches = batches.count
   288→        analysisProgress?.statusMessage = "Analyzing \(notes.count) notes in \(batches.count) batches (parallel mode)..."
   289→
   290→        // Use centralized prompt template
   291→        let template = PromptTemplate.connectionAnalysis(
   292→            threshold: minimumStrengthThreshold,
   293→            maxSuggestions: maxSuggestionsPerNote
   294→        )
   295→
   296→        // Pre-compute existing connections set once
   297→        let existingPairs = Set(existingConnections.map { "\($0.sourceNotePath) -> \($0.targetNotePath)" })
   298→
   299→        // Process batches in parallel groups
   300→        var allConnections: [NoteConnection] = []
   301→        var completedBatches = 0
   302→
   303→        // Group batches for parallel processing
   304→        let batchGroups = batches.enumerated().map { ($0.offset, $0.element) }.chunked(into: maxConcurrentBatches)
   305→
   306→        // Capture MainActor-isolated values before TaskGroup
   307→        let shouldUseMultiAgent = useMultiAgent && multiAgent.canUseMultiAgent
   308→
   309→        for batchGroup in batchGroups {
   310→            // Process this group in parallel using TaskGroup
   311→            let groupResults = await withTaskGroup(of: (Int, [NoteConnection], AnalysisError?).self) { group in
   312→                for (index, batch) in batchGroup {
   313→                    group.addTask { [weak self] in
   314→                        guard let self = self else { return (index, [], nil) }
   315→
   316→                        let prompt = PromptTemplate.multiNoteAnalysis(
   317→                            notes: batch.map { (path: $0.path, content: $0.excerpt) },
   318→                            allNotePaths: allNotePaths,
   319→                            existingConnections: existingPairs
   320→                        )
   321→
   322→                        do {
   323→                            let response: String
   324→
   325→                            if shouldUseMultiAgent {
   326→                                // Use analyzeBatch for concurrent processing (no isProcessing lock)
   327→                                let result = try await self.multiAgent.analyzeBatch(
   328→                                    content: prompt,
   329→                                    task: .custom(prompt: template.systemPrompt, systemPrompt: template.systemPrompt)
   330→                                )
   331→                                response = result.mergedResponse
   332→                            } else {
   333→                                response = try await self.lmService.analyzeContent(prompt, prompt: template.systemPrompt)
   334→                            }
   335→
   336→                            let connections = await MainActor.run {
   337→                                self.parseAIConnectionResponse(response, batchNotes: batch)
   338→                            }
   339→                            return (index, connections, nil)
   340→
   341→                        } catch {
   342→                            let analysisError = AnalysisError(
   343→                                notePath: "batch_\(index)",
   344→                                error: error.localizedDescription,
   345→                                phase: .analyzingContent
   346→                            )
   347→                            return (index, [], analysisError)
   348→                        }
   349→                    }
   350→                }
   351→
   352→                // Collect results from parallel tasks
   353→                var results: [(Int, [NoteConnection], AnalysisError?)] = []
   354→                for await result in group {
   355→                    results.append(result)
   356→                }
   357→                return results
   358→            }
   359→
   360→            // Process results from this parallel group
   361→            for (index, connections, error) in groupResults.sorted(by: { $0.0 < $1.0 }) {
   362→                allConnections.append(contentsOf: connections)
   363→                completedBatches += 1
   364→
   365→                if let error = error {
   366→                    Log.error("Batch \(index + 1) failed: \(error.error)", category: .ai)
   367→                    await MainActor.run {
   368→                        analysisProgress?.errors.append(error)
   369→                    }
   370→                } else {
   371→                    Log.debug("Batch \(index + 1) found \(connections.count) connections", category: .ai)
   372→                    await MainActor.run {
   373→                        analysisProgress?.recordConnections(connections.count)
   374→                    }
   375→                }
   376→
   377→                // Update progress
   378→                let batchNoteCount = batchGroup.first { $0.0 == index }?.1.count ?? 0
   379→                await MainActor.run {
   380→                    analysisProgress?.processedNotes += batchNoteCount
   381→                    analysisProgress?.statusMessage = "Completed batch \(completedBatches)/\(batches.count)..."
   382→
   383→                    let elapsed = Date().timeIntervalSince(analysisStartTime)
   384→                    analysisProgress?.updateThroughput(notesProcessed: analysisProgress?.processedNotes ?? 0, timeInterval: elapsed)
   385→                    analysisProgress?.updateBatchThroughput(batchesCompleted: completedBatches, timeInterval: elapsed)
   386→                }
   387→            }
   388→
   389→            // Small delay between parallel groups to respect rate limits
   390→            if completedBatches < batches.count {
   391→                try? await Task.sleep(nanoseconds: 500_000_000)  // 0.5s between groups
   392→            }
   393→        }
   394→
   395→        await MainActor.run {
   396→            analysisProgress?.currentBatchNotes = []
   397→            analysisProgress?.statusMessage = "Content analysis complete. Found \(allConnections.count) semantic connections."
   398→        }
   399→
   400→        Log.info("AI analysis complete (parallel): processed \(batches.count) batches, found \(allConnections.count) semantic connections", category: .ai)
   401→        return allConnections
   402→    }
   403→
   404→    // NOTE: buildConnectionAnalysisPrompt moved to PromptTemplate.multiNoteAnalysis
   405→
   406→    private func parseAIConnectionResponse(_ response: String, batchNotes: [(path: String, excerpt: String)]) -> [NoteConnection] {
   407→        // Try multiple strategies to extract JSON array
   408→        let jsonString = extractJSONArray(from: response)
   409→
   410→        guard !jsonString.isEmpty, let data = jsonString.data(using: .utf8) else {
   411→            Log.debug("No valid JSON array found in response (length: \(response.count))", category: .ai)
   412→            return []
   413→        }
   414→
   415→        do {
   416→            let suggestions = try JSONDecoder().decode([AIConnectionSuggestion].self, from: data)
   417→
   418→            return suggestions.compactMap { suggestion -> NoteConnection? in
   419→                guard suggestion.strength >= minimumStrengthThreshold else { return nil }
   420→
   421→                return NoteConnection(
   422→                    sourceNotePath: suggestion.source,
   423→                    targetNotePath: suggestion.target,
   424→                    connectionType: suggestion.connectionType,
   425→                    strength: suggestion.strength,
   426→                    reason: suggestion.reason,
   427→                    keywords: suggestion.keywords ?? []
   428→                )
   429→            }
   430→        } catch {
   431→            Log.warning("Failed to parse AI response: \(error.localizedDescription)", category: .ai)
   432→            Log.debug("Raw JSON: \(jsonString.prefix(200))...", category: .ai)
   433→            return []
   434→        }
   435→    }
   436→
   437→    /// Extract JSON array from AI response using multiple strategies
   438→    private func extractJSONArray(from response: String) -> String {
   439→        // Strategy 1: Try to find JSON in markdown code blocks
   440→        let codeBlockPattern = "```(?:json)?\\s*(\\[.*?\\])\\s*```"
   441→        if let regex = try? NSRegularExpression(pattern: codeBlockPattern, options: [.dotMatchesLineSeparators]),
   442→           let match = regex.firstMatch(in: response, options: [], range: NSRange(response.startIndex..., in: response)),
   443→           let range = Range(match.range(at: 1), in: response) {
   444→            let jsonCandidate = String(response[range])
   445→            if isValidJSON(jsonCandidate) {
   446→                return jsonCandidate
   447→            }
   448→        }
   449→
   450→        // Strategy 2: Find the outermost matching brackets
   451→        var bracketCount = 0
   452→        var startIndex: String.Index?
   453→        var endIndex: String.Index?
   454→
   455→        for (index, char) in response.enumerated() {
   456→            let strIndex = response.index(response.startIndex, offsetBy: index)
   457→            if char == "[" {
   458→                if bracketCount == 0 {
   459→                    startIndex = strIndex
   460→                }
   461→                bracketCount += 1
   462→            } else if char == "]" {
   463→                bracketCount -= 1
   464→                if bracketCount == 0 && startIndex != nil {
   465→                    endIndex = strIndex
   466→                    break
   467→                }
   468→            }
   469→        }
   470→
   471→        if let start = startIndex, let end = endIndex {
   472→            let jsonCandidate = String(response[start...end])
   473→            if isValidJSON(jsonCandidate) {
   474→                return jsonCandidate
   475→            }
   476→        }
   477→
   478→        // Strategy 3: Simple first/last bracket match (original behavior)
   479→        if let firstBracket = response.firstIndex(of: "["),
   480→           let lastBracket = response.lastIndex(of: "]") {
   481→            let jsonCandidate = String(response[firstBracket...lastBracket])
   482→            if isValidJSON(jsonCandidate) {
   483→                return jsonCandidate
   484→            }
   485→        }
   486→
   487→        // Strategy 4: Return empty array if response indicates no connections
   488→        let lowercased = response.lowercased()
   489→        if lowercased.contains("no connection") || lowercased.contains("no semantic") ||
   490→           lowercased.contains("not find") || lowercased.contains("empty") ||
   491→           lowercased.contains("[]") {
   492→            return "[]"
   493→        }
   494→
   495→        return ""
   496→    }
   497→
   498→    /// Check if string is valid JSON array
   499→    private func isValidJSON(_ string: String) -> Bool {
   500→        guard let data = string.data(using: .utf8) else { return false }
   501→        do {
   502→            let obj = try JSONSerialization.jsonObject(with: data, options: [])
   503→            return obj is [[String: Any]] // Must be array of dictionaries
   504→        } catch {
   505→            return false
   506→        }
   507→    }
   508→
   509→    // MARK: - Helper Methods
   510→
   511→    private func updateIncomingCounts(nodes: inout [String: NoteNode], connections: [NoteConnection]) {
   512→        // OPTIMIZED: Single pass to count incoming connections
   513→        // Pre-count all incoming connections first
   514→        var incomingCounts: [String: Int] = [:]
   515→        for connection in connections {
   516→            incomingCounts[connection.targetNotePath, default: 0] += 1
   517→        }
   518→
   519→        // Single pass: update counts AND orphan status together (avoid multiple iterations)
   520→        for key in nodes.keys {
   521→            let incoming = incomingCounts[key] ?? 0
   522→            nodes[key]?.incomingConnections = incoming
   523→            // Update orphan status in same pass
   524→            if let outgoing = nodes[key]?.outgoingConnections {
   525→                nodes[key]?.isOrphan = incoming == 0 && outgoing == 0
   526→            }
   527→        }
   528→    }
   529→
   530→    private func identifyClusters(nodes: [NoteNode], connections: [NoteConnection]) -> [NoteCluster] {
   531→        // Simple clustering based on shared tags
   532→        var clusters: [NoteCluster] = []
   533→        var tagGroups: [String: [String]] = [:]
   534→
   535→        for node in nodes {
   536→            for tag in node.tags {
   537→                tagGroups[tag, default: []].append(node.path)
   538→            }
   539→        }
   540→
   541→        // Create clusters from tags with multiple notes
   542→        let colors = ["#8B5CF6", "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#EC4899"]
   543→        var colorIndex = 0
   544→
   545→        for (tag, notePaths) in tagGroups where notePaths.count >= 3 {
   546→            clusters.append(NoteCluster(
   547→                name: "#\(tag)",
   548→                theme: "Notes tagged with #\(tag)",
   549→                noteIds: notePaths,
   550→                keywords: [tag],
   551→                colorHex: colors[colorIndex % colors.count]
   552→            ))
   553→            colorIndex += 1
   554→        }
   555→
   556→        return clusters
   557→    }
   558→
   559→    // MARK: - Feedback Loop Actions
   560→
   561→    /// Accept a suggestion and optionally write to vault
   562→    func acceptSuggestion(_ suggestion: ConnectionSuggestion) async throws {
   563→        Log.info("Accepting suggestion: \(suggestion.suggestedConnection.sourceNotePath) → \(suggestion.suggestedConnection.targetNotePath)", category: .vault)
   564→        var updated = suggestion
   565→        updated.status = .accepted
   566→        updated.processedAt = Date()
   567→
   568→        var connection = suggestion.suggestedConnection
   569→        connection.isUserAccepted = true
   570→        connection.updatedAt = Date()
   571→
   572→        // Add to vault graph
   573→        vaultGraph?.edges.append(connection)
   574→        vaultGraph?.connectionCount += 1
   575→
   576→        // Update metrics
   577→        feedbackMetrics.recordAcceptance()
   578→
   579→        // Write to vault if enabled
   580→        if autoWriteConnections {
   581→            try await writeConnectionToVault(connection)
   582→            feedbackMetrics.recordConnectionWritten()
   583→        }
   584→
   585→        // Learn from acceptance
   586→        learnFromFeedback(connection: connection, accepted: true)
   587→
   588→        // Remove from pending
   589→        pendingSuggestions.removeAll { $0.id == suggestion.id }
   590→
   591→        // Persist
   592→        try persistence.updateSuggestion(updated)
   593→        if let graph = vaultGraph {
   594→            try persistence.saveGraph(graph)
   595→        }
   596→        try persistence.saveMetrics(feedbackMetrics)
   597→        try persistence.saveLearningWeights(learningWeights)
   598→    }
   599→
   600→    /// Reject a suggestion
   601→    func rejectSuggestion(_ suggestion: ConnectionSuggestion) async throws {
   602→        Log.info("Rejecting suggestion: \(suggestion.suggestedConnection.sourceNotePath) → \(suggestion.suggestedConnection.targetNotePath)", category: .vault)
   603→        var updated = suggestion
   604→        updated.status = .rejected
   605→        updated.processedAt = Date()
   606→
   607→        var connection = suggestion.suggestedConnection
   608→        connection.isUserRejected = true
   609→
   610→        // Update metrics
   611→        feedbackMetrics.recordRejection()
   612→
   613→        // Learn from rejection
   614→        learnFromFeedback(connection: connection, accepted: false)
   615→
   616→        // Remove from pending
   617→        pendingSuggestions.removeAll { $0.id == suggestion.id }
   618→
   619→        // Persist
   620→        try persistence.updateSuggestion(updated)
   621→        try persistence.saveMetrics(feedbackMetrics)
   622→        try persistence.saveLearningWeights(learningWeights)
   623→    }
   624→
   625→    /// Defer a suggestion for later
   626→    func deferSuggestion(_ suggestion: ConnectionSuggestion) async throws {
   627→        var updated = suggestion
   628→        updated.status = .deferred
   629→
   630→        feedbackMetrics.recordDeferred()
   631→
   632→        // Remove from pending (will reappear on next analysis)
   633→        pendingSuggestions.removeAll { $0.id == suggestion.id }
   634→
   635→        try persistence.updateSuggestion(updated)
   636→        try persistence.saveMetrics(feedbackMetrics)
   637→    }
   638→
   639→    // MARK: - Write to Vault
   640→
   641→    /// Write accepted connection to the source note's ## Conexões section
   642→    func writeConnectionToVault(_ connection: NoteConnection) async throws {
   643→        Log.debug("Writing connection to vault: \(connection.sourceNotePath)", category: .vault)
   644→        let content = try await obsidianService.getFileContents(connection.sourceNotePath)
   645→
   646→        let targetName = URL(fileURLWithPath: connection.targetNotePath)
   647→            .deletingPathExtension().lastPathComponent
   648→        let connectionLine = "- [[\(targetName)]] - \(connection.reason)"
   649→
   650→        // Check if Conexões section exists
   651→        if content.contains("## Conexões") || content.contains("## Conexoes") || content.contains("## 🔗 Conexões") {
   652→            // Append to existing section
   653→            try await obsidianService.appendContent(
   654→                to: connection.sourceNotePath,
   655→                content: "\n\(connectionLine)"
   656→            )
   657→        } else {
   658→            // Create new section at the end
   659→            let newSection = "\n\n## 🔗 Conexões\n\(connectionLine)"
   660→            try await obsidianService.appendContent(
   661→                to: connection.sourceNotePath,
   662→                content: newSection
   663→            )
   664→        }
   665→    }
   666→
   667→    // MARK: - Feedback Loop Learning
   668→
   669→    private func learnFromFeedback(connection: NoteConnection, accepted: Bool) {
   670→        let adjustment = accepted ? 0.1 : -0.05
   671→
   672→        // Adjust type weight
   673→        learningWeights.adjustWeight(for: connection.connectionType, by: adjustment)
   674→
   675→        // Adjust keyword importance
   676→        for keyword in connection.keywords {
   677→            learningWeights.adjustImportance(for: keyword, by: adjustment)
   678→        }
   679→    }
   680→
   681→    // MARK: - Orphan Management
   682→
   683→    /// Get all orphan notes
   684→    func getOrphanNotes() -> [NoteNode] {
   685→        vaultGraph?.orphanNodes ?? []
   686→    }
   687→
   688→    /// Suggest connections for a specific orphan note
   689→    func suggestConnectionsForOrphan(_ orphan: NoteNode) async throws -> [ConnectionSuggestion] {
   690→        Log.debug("Suggesting connections for orphan: \(orphan.title)", category: .ai)
   691→        let content = try await obsidianService.getFileContents(orphan.path)
   692→
   693→        // Get sample of other notes to compare
   694→        let otherNotes = Array((vaultGraph?.nodes.values.filter { $0.id != orphan.id } ?? []).prefix(30))
   695→
   696→        // Use full content or intelligent chunking for large notes
   697→        let contentToAnalyze: String
   698→        if content.count > 6000 {
   699→            let start = String(content.prefix(3000))
   700→            let end = String(content.suffix(2000))
   701→            contentToAnalyze = "\(start)\n\n[...content truncated...]\n\n\(end)"
   702→        } else {
   703→            contentToAnalyze = content
   704→        }
   705→
   706→        // Use centralized prompt template
   707→        let availableNotesList = otherNotes.map { "\($0.title) (\($0.path))" }
   708→        let template = PromptTemplate.orphanAnalysis(
   709→            title: orphan.title,
   710→            availableNotes: availableNotesList
   711→        )
   712→
   713→        // Use multi-agent if enabled and available
   714→        let response: String
   715→        if useMultiAgent && multiAgent.canUseMultiAgent {
   716→            Log.info("SmartConnections: Using multi-agent for orphan analysis", category: .ai)
   717→            let result = try await multiAgent.analyze(
   718→                content: contentToAnalyze,
   719→                task: .identifyOrphans
   720→            )
   721→            response = result.mergedResponse
   722→        } else {
   723→            response = try await lmService.analyzeContent(contentToAnalyze, prompt: template.systemPrompt)
   724→        }
   725→
   726→        // Parse response
   727→        guard let data = response.data(using: .utf8),
   728→              let suggestions = try? JSONDecoder().decode([AIConnectionSuggestion].self, from: data) else {
   729→            return []
   730→        }
   731→
   732→        return suggestions.compactMap { suggestion -> ConnectionSuggestion? in
   733→            guard suggestion.strength >= minimumStrengthThreshold else { return nil }
   734→
   735→            let connection = NoteConnection(
   736→                sourceNotePath: orphan.path,
   737→                targetNotePath: suggestion.target,
   738→                connectionType: suggestion.connectionType,
   739→                strength: suggestion.strength,
   740→                reason: suggestion.reason,
   741→                keywords: suggestion.keywords ?? []
   742→            )
   743→
   744→            return ConnectionSuggestion(suggestedConnection: connection)
   745→        }
   746→    }
   747→
   748→    // MARK: - Clear Data
   749→
   750→    func clearAllData() {
   751→        Log.warning("Clearing all Smart Connections data", category: .vault)
   752→        persistence.clearAllData()
   753→        vaultGraph = nil
   754→        pendingSuggestions = []
   755→        feedbackMetrics = FeedbackMetrics()
   756→        learningWeights = LearningWeights()
   757→        analysisProgress = nil
   758→        Log.info("All Smart Connections data cleared", category: .vault)
   759→    }
   760→}
   761→

<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
import Combine

/// Core AI Agent service for Smart Connections
/// Orchestrates vault analysis, connection discovery, and feedback loop learning
@MainActor
class SmartConnectionService: ObservableObject {

    // MARK: - Dependencies

    private let obsidianService: ObsidianService
    private let lmService: LMGatewayService
    private let multiAgent: DualAgentProcessor
    private let persistence: ConnectionPersistence
    private let wikiLinkParser: WikiLinkParser

    // MARK: - Published State

    @Published var vaultGraph: VaultGraph?
    @Published var pendingSuggestions: [ConnectionSuggestion] = []
    @Published var analysisProgress: AnalysisProgress?
    @Published var feedbackMetrics: FeedbackMetrics
    @Published var learningWeights: LearningWeights
    @Published var isAnalyzing: Bool = false
    @Published var lastError: String?

    // MARK: - Configuration

    @AppStorage("smartConnections_batchSize") var batchSize: Int = 10
    @AppStorage("smartConnections_minimumStrength") var minimumStrengthThreshold: Double = 0.5
    @AppStorage("smartConnections_maxSuggestionsPerNote") var maxSuggestionsPerNote: Int = 5
    @AppStorage("smartConnections_autoWriteConnections") var autoWriteConnections: Bool = true
    @AppStorage("smartConnections_useMultiAgent") var useMultiAgent: Bool = true

    // MARK: - Init

    init(
        obsidianService: ObsidianService,
        lmService: LMGatewayService,
        multiAgent: DualAgentProcessor = .shared,
        persistence: ConnectionPersistence = ConnectionPersistence(),
        wikiLinkParser: WikiLinkParser = WikiLinkParser()
    ) {
        self.obsidianService = obsidianService
        self.lmService = lmService
        self.multiAgent = multiAgent
        self.persistence = persistence
        self.wikiLinkParser = wikiLinkParser
        self.feedbackMetrics = persistence.loadMetrics()
        self.learningWeights = persistence.loadLearningWeights()
    }

    // MARK: - Load Persisted Data

    func loadPersistedData() {
        vaultGraph = persistence.loadGraph()
        pendingSuggestions = persistence.loadSuggestions().filter { $0.status == .pending }
        feedbackMetrics = persistence.loadMetrics()
        learningWeights = persistence.loadLearningWeights()
    }

    // MARK: - Full Vault Analysis Pipeline

    /// Full vault analysis (initial or refresh)
    func analyzeVault() async throws {
        guard !isAnalyzing else {
            Log.warning("Analysis already in progress, skipping", category: .vault)
            return
        }

        Log.info("Starting vault analysis...", category: .vault)
        isAnalyzing = true
        lastError = nil

        // Initialize progress with all new fields
        var progress = AnalysisProgress(
            totalNotes: 0,
            processedNotes: 0,
            currentPhase: .idle,
            startedAt: Date()
        )
        progress.startPhase(.loadingFiles)
        analysisProgress = progress

        defer {
            isAnalyzing = false
            if let elapsed = analysisProgress?.elapsedTime {
                Log.info("Analysis completed in \(String(format: "%.1f", elapsed))s", category: .vault)
            }
        }

        do {
            // Phase 1: Load all files RECURSIVELY (includes nested folders)
            Log.debug("Phase 1: Loading files from vault...", category: .vault)
            analysisProgress?.statusMessage = "Loading files from vault..."
            let markdownFiles = try await obsidianService.listAllMarkdownFiles()
            analysisProgress?.totalNotes = markdownFiles.count
            analysisProgress?.statusMessage = "Found \(markdownFiles.count) notes"
            Log.info("Found \(markdownFiles.count) markdown files", category: .vault)

            // Phase 2: Load content in BATCHES and parse wiki-links (memory optimized)
            Log.debug("Phase 2: Parsing wiki-links and metadata (batch mode)...", category: .vault)
            analysisProgress?.startPhase(.parsingWikiLinks)
            analysisProgress?.statusMessage = "Parsing wiki-links and metadata..."
            var nodes: [String: NoteNode] = [:]
            var existingConnections: [NoteConnection] = []
            let parsingStartTime = Date()

            // Process files in batches to reduce memory footprint
            let fileBatchSize = 20  // Load 20 files at a time
            let fileBatches = markdownFiles.chunked(into: fileBatchSize)
            var allNotePaths: [String] = []  // Just paths, not content
            var noteExcerpts: [(path: String, excerpt: String)] = []  // Truncated content for AI

            // UI update batching: only update every N files to reduce jank
            let uiUpdateInterval = 10
            var localProcessedCount = 0
            var localConnectionCount = 0

            for (batchIndex, fileBatch) in fileBatches.enumerated() {
                let batchPaths = fileBatch.map { $0.path }

                // Update status only once per batch (not per file)
                if batchIndex % 3 == 0 {  // Even less frequent status updates
                    analysisProgress?.statusMessage = "Loading batch \(batchIndex + 1)/\(fileBatches.count)..."
                }

                // Parallel batch loading
                let batchContents = try await obsidianService.getBatchFileContents(batchPaths)

                // Process each file in batch, then release full content
                for (fileIndex, file) in fileBatch.enumerated() {
                    guard let content = batchContents[file.path] else { continue }

                    // Only update currentNotePath every N files to reduce UI updates
                    if localProcessedCount % uiUpdateInterval == 0 {
                        analysisProgress?.currentNotePath = file.path
                    }

                    // Parse all metadata in one pass (using existing parser)
                    let wikiLinks = wikiLinkParser.parseWikiLinks(from: content)
                    let tags = wikiLinkParser.extractTags(from: content)
                    let metadata = wikiLinkParser.parseMetadata(from: content)
                    let hash = wikiLinkParser.contentHash(for: content)
                    let title = wikiLinkParser.extractTitle(from: content) ?? file.displayName

                    // Store only path and truncated excerpt for AI analysis (max 2000 chars)
                    let excerpt = String(content.prefix(2000))
                    noteExcerpts.append((file.path, excerpt))
                    allNotePaths.append(file.path)

                    // Create node (metadata only, no full content)
                    nodes[file.path] = NoteNode(
                        path: file.path,
                        title: title,
                        incomingConnections: 0,
                        outgoingConnections: wikiLinks.count,
                        clusterIds: [],
                        isOrphan: wikiLinks.isEmpty,
                        lastAnalyzed: Date(),
                        contentHash: hash,
                        tags: tags,
                        metadata: metadata
                    )

                    // Create wiki-link connections
                    for link in wikiLinks {
                        existingConnections.append(NoteConnection(
                            sourceNotePath: file.path,
                            targetNotePath: link,
                            connectionType: .wikiLink,
                            strength: 1.0,
                            reason: "Explicit wiki-link in source note",
                            isUserAccepted: true,
                            keywords: []
                        ))
                    }

                    localProcessedCount += 1
                    localConnectionCount += wikiLinks.count

                    // Batch UI updates: only update @Published property every N files
                    if localProcessedCount % uiUpdateInterval == 0 {
                        analysisProgress?.processedNotes = localProcessedCount
                        analysisProgress?.totalConnectionsFound = localConnectionCount
                    }
                }
                // batchContents is released here when it goes out of scope

                // Update throughput every batch (not per file)
                let elapsed = Date().timeIntervalSince(parsingStartTime)
                analysisProgress?.updateThroughput(notesProcessed: localProcessedCount, timeInterval: elapsed)
            }

            // Final sync of progress after all batches
            analysisProgress?.processedNotes = localProcessedCount
            analysisProgress?.totalConnectionsFound = localConnectionCount

            // Final throughput update for parsing phase
            let parsingElapsed = Date().timeIntervalSince(parsingStartTime)
            analysisProgress?.updateThroughput(notesProcessed: markdownFiles.count, timeInterval: parsingElapsed)
            Log.info("Phase 2 complete: parsed \(nodes.count) notes, found \(existingConnections.count) wiki-links in \(String(format: "%.1f", parsingElapsed))s (memory optimized)", category: .vault)

            // Phase 3: AI semantic analysis (batched) - using excerpts, not full content
            Log.debug("Phase 3: Starting AI semantic analysis...", category: .ai)
            analysisProgress?.startPhase(.analyzingContent)
            analysisProgress?.processedNotes = 0
            let aiConnections = try await analyzeContentInBatches(
                notes: noteExcerpts,  // Using truncated excerpts (max 2000 chars each)
                allNotePaths: allNotePaths,  // Pass paths separately to avoid recomputing
                existingConnections: existingConnections
            )

            // Phase 4: Build graph
            Log.debug("Phase 4: Building knowledge graph...", category: .vault)
            analysisProgress?.startPhase(.buildingGraph)
            analysisProgress?.statusMessage = "Building knowledge graph..."
            updateIncomingCounts(nodes: &nodes, connections: existingConnections + aiConnections)
            Log.debug("Graph built with \(nodes.count) nodes, \(existingConnections.count + aiConnections.count) connections", category: .vault)

            // Phase 5: Identify clusters
            Log.debug("Phase 5: Identifying topic clusters...", category: .vault)
            analysisProgress?.startPhase(.identifyingClusters)
            analysisProgress?.statusMessage = "Identifying topic clusters..."
            let clusters = identifyClusters(nodes: Array(nodes.values), connections: existingConnections)
            Log.info("Identified \(clusters.count) topic clusters", category: .vault)

            // Phase 6: Generate suggestions
            Log.debug("Phase 6: Generating connection suggestions...", category: .ai)
            analysisProgress?.startPhase(.generatingSuggestions)
            analysisProgress?.statusMessage = "Generating connection suggestions..."
            let suggestions = aiConnections
                .filter { !$0.isUserAccepted && !$0.isUserRejected && $0.strength >= minimumStrengthThreshold }
                .map { ConnectionSuggestion(suggestedConnection: $0, batchId: UUID()) }

            // Update state
            vaultGraph = VaultGraph(
                nodes: nodes,
                edges: existingConnections,
                clusters: clusters,
                lastFullAnalysis: Date(),
                totalNotes: markdownFiles.count,
                orphanCount: nodes.values.filter { $0.isOrphan }.count,
                connectionCount: existingConnections.count
            )

            pendingSuggestions = suggestions
            feedbackMetrics.totalSuggestionsGenerated += suggestions.count

            // Persist
            Log.debug("Saving analysis results...", category: .vault)
            try persistence.saveGraph(vaultGraph!)
            try persistence.saveSuggestions(suggestions)
            try persistence.saveMetrics(feedbackMetrics)
            Log.info("Analysis results saved successfully", category: .vault)

            // Mark complete with final status
            analysisProgress?.markComplete()
            analysisProgress?.statusMessage = "Found \(suggestions.count) new suggestions"

            // Final summary log
            Log.info("Analysis complete: \(nodes.count) notes, \(existingConnections.count) wiki-links, \(aiConnections.count) AI connections, \(suggestions.count) suggestions, \(clusters.count) clusters", category: .vault)

        } catch {
            Log.error("Analysis failed: \(error.localizedDescription)", category: .vault)
            lastError = error.localizedDescription
            analysisProgress?.markFailed(error: error.localizedDescription)
            throw error
        }
    }

    // MARK: - Batch Content Analysis (Parallel Processing)

    /// Maximum concurrent API requests (to respect rate limits)
    private let maxConcurrentBatches = 2

    private func analyzeContentInBatches(
        notes: [(path: String, excerpt: String)],
        allNotePaths: [String],
        existingConnections: [NoteConnection]
    ) async throws -> [NoteConnection] {
        let analysisStartTime = Date()

        // Create batches
        let batches = notes.chunked(into: batchSize)
        analysisProgress?.totalBatches = batches.count
        analysisProgress?.statusMessage = "Analyzing \(notes.count) notes in \(batches.count) batches (parallel mode)..."

        // Use centralized prompt template
        let template = PromptTemplate.connectionAnalysis(
            threshold: minimumStrengthThreshold,
            maxSuggestions: maxSuggestionsPerNote
        )

        // Pre-compute existing connections set once
        let existingPairs = Set(existingConnections.map { "\($0.sourceNotePath) -> \($0.targetNotePath)" })

        // Process batches in parallel groups
        var allConnections: [NoteConnection] = []
        var completedBatches = 0

        // Group batches for parallel processing
        let batchGroups = batches.enumerated().map { ($0.offset, $0.element) }.chunked(into: maxConcurrentBatches)

        // Capture MainActor-isolated values before TaskGroup
        let shouldUseMultiAgent = useMultiAgent && multiAgent.canUseMultiAgent

        for batchGroup in batchGroups {
            // Process this group in parallel using TaskGroup
            let groupResults = await withTaskGroup(of: (Int, [NoteConnection], AnalysisError?).self) { group in
                for (index, batch) in batchGroup {
                    group.addTask { [weak self] in
                        guard let self = self else { return (index, [], nil) }

                        let prompt = PromptTemplate.multiNoteAnalysis(
                            notes: batch.map { (path: $0.path, content: $0.excerpt) },
                            allNotePaths: allNotePaths,
                            existingConnections: existingPairs
                        )

                        do {
                            let response: String

                            if shouldUseMultiAgent {
                                // Use analyzeBatch for concurrent processing (no isProcessing lock)
                                let result = try await self.multiAgent.analyzeBatch(
                                    content: prompt,
                                    task: .custom(prompt: template.systemPrompt, systemPrompt: template.systemPrompt)
                                )
                                response = result.mergedResponse
                            } else {
                                response = try await self.lmService.analyzeContent(prompt, prompt: template.systemPrompt)
                            }

                            let connections = await MainActor.run {
                                self.parseAIConnectionResponse(response, batchNotes: batch)
                            }
                            return (index, connections, nil)

                        } catch {
                            let analysisError = AnalysisError(
                                notePath: "batch_\(index)",
                                error: error.localizedDescription,
                                phase: .analyzingContent
                            )
                            return (index, [], analysisError)
                        }
                    }
                }

                // Collect results from parallel tasks
                var results: [(Int, [NoteConnection], AnalysisError?)] = []
                for await result in group {
                    results.append(result)
                }
                return results
            }

            // Process results from this parallel group
            for (index, connections, error) in groupResults.sorted(by: { $0.0 < $1.0 }) {
                allConnections.append(contentsOf: connections)
                completedBatches += 1

                if let error = error {
                    Log.error("Batch \(index + 1) failed: \(error.error)", category: .ai)
                    await MainActor.run {
                        analysisProgress?.errors.append(error)
                    }
                } else {
                    Log.debug("Batch \(index + 1) found \(connections.count) connections", category: .ai)
                    await MainActor.run {
                        analysisProgress?.recordConnections(connections.count)
                    }
                }

                // Update progress
                let batchNoteCount = batchGroup.first { $0.0 == index }?.1.count ?? 0
                await MainActor.run {
                    analysisProgress?.processedNotes += batchNoteCount
                    analysisProgress?.statusMessage = "Completed batch \(completedBatches)/\(batches.count)..."

                    let elapsed = Date().timeIntervalSince(analysisStartTime)
                    analysisProgress?.updateThroughput(notesProcessed: analysisProgress?.processedNotes ?? 0, timeInterval: elapsed)
                    analysisProgress?.updateBatchThroughput(batchesCompleted: completedBatches, timeInterval: elapsed)
                }
            }

            // Small delay between parallel groups to respect rate limits
            if completedBatches < batches.count {
                try? await Task.sleep(nanoseconds: 500_000_000)  // 0.5s between groups
            }
        }

        await MainActor.run {
            analysisProgress?.currentBatchNotes = []
            analysisProgress?.statusMessage = "Content analysis complete. Found \(allConnections.count) semantic connections."
        }

        Log.info("AI analysis complete (parallel): processed \(batches.count) batches, found \(allConnections.count) semantic connections", category: .ai)
        return allConnections
    }

    // NOTE: buildConnectionAnalysisPrompt moved to PromptTemplate.multiNoteAnalysis

    private func parseAIConnectionResponse(_ response: String, batchNotes: [(path: String, excerpt: String)]) -> [NoteConnection] {
        // Try multiple strategies to extract JSON array
        let jsonString = extractJSONArray(from: response)

        guard !jsonString.isEmpty, let data = jsonString.data(using: .utf8) else {
            Log.debug("No valid JSON array found in response (length: \(response.count))", category: .ai)
            return []
        }

        do {
            let suggestions = try JSONDecoder().decode([AIConnectionSuggestion].self, from: data)

            return suggestions.compactMap { suggestion -> NoteConnection? in
                guard suggestion.strength >= minimumStrengthThreshold else { return nil }

                return NoteConnection(
                    sourceNotePath: suggestion.source,
                    targetNotePath: suggestion.target,
                    connectionType: suggestion.connectionType,
                    strength: suggestion.strength,
                    reason: suggestion.reason,
                    keywords: suggestion.keywords ?? []
                )
            }
        } catch {
            Log.warning("Failed to parse AI response: \(error.localizedDescription)", category: .ai)
            Log.debug("Raw JSON: \(jsonString.prefix(200))...", category: .ai)
            return []
        }
    }

    /// Extract JSON array from AI response using multiple strategies
    private func extractJSONArray(from response: String) -> String {
        // Strategy 1: Try to find JSON in markdown code blocks
        let codeBlockPattern = "```(?:json)?\\s*(\\[.*?\\])\\s*```"
        if let regex = try? NSRegularExpression(pattern: codeBlockPattern, options: [.dotMatchesLineSeparators]),
           let match = regex.firstMatch(in: response, options: [], range: NSRange(response.startIndex..., in: response)),
           let range = Range(match.range(at: 1), in: response) {
            let jsonCandidate = String(response[range])
            if isValidJSON(jsonCandidate) {
                return jsonCandidate
            }
        }

        // Strategy 2: Find the outermost matching brackets
        var bracketCount = 0
        var startIndex: String.Index?
        var endIndex: String.Index?

        for (index, char) in response.enumerated() {
            let strIndex = response.index(response.startIndex, offsetBy: index)
            if char == "[" {
                if bracketCount == 0 {
                    startIndex = strIndex
                }
                bracketCount += 1
            } else if char == "]" {
                bracketCount -= 1
                if bracketCount == 0 && startIndex != nil {
                    endIndex = strIndex
                    break
                }
            }
        }

        if let start = startIndex, let end = endIndex {
            let jsonCandidate = String(response[start...end])
            if isValidJSON(jsonCandidate) {
                return jsonCandidate
            }
        }

        // Strategy 3: Simple first/last bracket match (original behavior)
        if let firstBracket = response.firstIndex(of: "["),
           let lastBracket = response.lastIndex(of: "]") {
            let jsonCandidate = String(response[firstBracket...lastBracket])
            if isValidJSON(jsonCandidate) {
                return jsonCandidate
            }
        }

        // Strategy 4: Return empty array if response indicates no connections
        let lowercased = response.lowercased()
        if lowercased.contains("no connection") || lowercased.contains("no semantic") ||
           lowercased.contains("not find") || lowercased.contains("empty") ||
           lowercased.contains("[]") {
            return "[]"
        }

        return ""
    }

    /// Check if string is valid JSON array
    private func isValidJSON(_ string: String) -> Bool {
        guard let data = string.data(using: .utf8) else { return false }
        do {
            let obj = try JSONSerialization.jsonObject(with: data, options: [])
            return obj is [[String: Any]] // Must be array of dictionaries
        } catch {
            return false
        }
    }

    // MARK: - Helper Methods

    private func updateIncomingCounts(nodes: inout [String: NoteNode], connections: [NoteConnection]) {
        // OPTIMIZED: Single pass to count incoming connections
        // Pre-count all incoming connections first
        var incomingCounts: [String: Int] = [:]
        for connection in connections {
            incomingCounts[connection.targetNotePath, default: 0] += 1
        }

        // Single pass: update counts AND orphan status together (avoid multiple iterations)
        for key in nodes.keys {
            let incoming = incomingCounts[key] ?? 0
            nodes[key]?.incomingConnections = incoming
            // Update orphan status in same pass
            if let outgoing = nodes[key]?.outgoingConnections {
                nodes[key]?.isOrphan = incoming == 0 && outgoing == 0
            }
        }
    }

    private func identifyClusters(nodes: [NoteNode], connections: [NoteConnection]) -> [NoteCluster] {
        // Simple clustering based on shared tags
        var clusters: [NoteCluster] = []
        var tagGroups: [String: [String]] = [:]

        for node in nodes {
            for tag in node.tags {
                tagGroups[tag, default: []].append(node.path)
            }
        }

        // Create clusters from tags with multiple notes
        let colors = ["#8B5CF6", "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#EC4899"]
        var colorIndex = 0

        for (tag, notePaths) in tagGroups where notePaths.count >= 3 {
            clusters.append(NoteCluster(
                name: "#\(tag)",
                theme: "Notes tagged with #\(tag)",
                noteIds: notePaths,
                keywords: [tag],
                colorHex: colors[colorIndex % colors.count]
            ))
            colorIndex += 1
        }

        return clusters
    }

    // MARK: - Feedback Loop Actions

    /// Accept a suggestion and optionally write to vault
    func acceptSuggestion(_ suggestion: ConnectionSuggestion) async throws {
        Log.info("Accepting suggestion: \(suggestion.suggestedConnection.sourceNotePath) → \(suggestion.suggestedConnection.targetNotePath)", category: .vault)
        var updated = suggestion
        updated.status = .accepted
        updated.processedAt = Date()

        var connection = suggestion.suggestedConnection
        connection.isUserAccepted = true
        connection.updatedAt = Date()

        // Add to vault graph
        vaultGraph?.edges.append(connection)
        vaultGraph?.connectionCount += 1

        // Update metrics
        feedbackMetrics.recordAcceptance()

        // Write to vault if enabled
        if autoWriteConnections {
            try await writeConnectionToVault(connection)
            feedbackMetrics.recordConnectionWritten()
        }

        // Learn from acceptance
        learnFromFeedback(connection: connection, accepted: true)

        // Remove from pending
        pendingSuggestions.removeAll { $0.id == suggestion.id }

        // Persist
        try persistence.updateSuggestion(updated)
        if let graph = vaultGraph {
            try persistence.saveGraph(graph)
        }
        try persistence.saveMetrics(feedbackMetrics)
        try persistence.saveLearningWeights(learningWeights)
    }

    /// Reject a suggestion
    func rejectSuggestion(_ suggestion: ConnectionSuggestion) async throws {
        Log.info("Rejecting suggestion: \(suggestion.suggestedConnection.sourceNotePath) → \(suggestion.suggestedConnection.targetNotePath)", category: .vault)
        var updated = suggestion
        updated.status = .rejected
        updated.processedAt = Date()

        var connection = suggestion.suggestedConnection
        connection.isUserRejected = true

        // Update metrics
        feedbackMetrics.recordRejection()

        // Learn from rejection
        learnFromFeedback(connection: connection, accepted: false)

        // Remove from pending
        pendingSuggestions.removeAll { $0.id == suggestion.id }

        // Persist
        try persistence.updateSuggestion(updated)
        try persistence.saveMetrics(feedbackMetrics)
        try persistence.saveLearningWeights(learningWeights)
    }

    /// Defer a suggestion for later
    func deferSuggestion(_ suggestion: ConnectionSuggestion) async throws {
        var updated = suggestion
        updated.status = .deferred

        feedbackMetrics.recordDeferred()

        // Remove from pending (will reappear on next analysis)
        pendingSuggestions.removeAll { $0.id == suggestion.id }

        try persistence.updateSuggestion(updated)
        try persistence.saveMetrics(feedbackMetrics)
    }

    // MARK: - Write to Vault

    /// Write accepted connection to the source note's ## Conexões section
    func writeConnectionToVault(_ connection: NoteConnection) async throws {
        Log.debug("Writing connection to vault: \(connection.sourceNotePath)", category: .vault)
        let content = try await obsidianService.getFileContents(connection.sourceNotePath)

        let targetName = URL(fileURLWithPath: connection.targetNotePath)
            .deletingPathExtension().lastPathComponent
        let connectionLine = "- [[\(targetName)]] - \(connection.reason)"

        // Check if Conexões section exists
        if content.contains("## Conexões") || content.contains("## Conexoes") || content.contains("## 🔗 Conexões") {
            // Append to existing section
            try await obsidianService.appendContent(
                to: connection.sourceNotePath,
                content: "\n\(connectionLine)"
            )
        } else {
            // Create new section at the end
            let newSection = "\n\n## 🔗 Conexões\n\(connectionLine)"
            try await obsidianService.appendContent(
                to: connection.sourceNotePath,
                content: newSection
            )
        }
    }

    // MARK: - Feedback Loop Learning

    private func learnFromFeedback(connection: NoteConnection, accepted: Bool) {
        let adjustment = accepted ? 0.1 : -0.05

        // Adjust type weight
        learningWeights.adjustWeight(for: connection.connectionType, by: adjustment)

        // Adjust keyword importance
        for keyword in connection.keywords {
            learningWeights.adjustImportance(for: keyword, by: adjustment)
        }
    }

    // MARK: - Orphan Management

    /// Get all orphan notes
    func getOrphanNotes() -> [NoteNode] {
        vaultGraph?.orphanNodes ?? []
    }

    /// Suggest connections for a specific orphan note
    func suggestConnectionsForOrphan(_ orphan: NoteNode) async throws -> [ConnectionSuggestion] {
        Log.debug("Suggesting connections for orphan: \(orphan.title)", category: .ai)
        let content = try await obsidianService.getFileContents(orphan.path)

        // Get sample of other notes to compare
        let otherNotes = Array((vaultGraph?.nodes.values.filter { $0.id != orphan.id } ?? []).prefix(30))

        // Use full content or intelligent chunking for large notes
        let contentToAnalyze: String
        if content.count > 6000 {
            let start = String(content.prefix(3000))
            let end = String(content.suffix(2000))
            contentToAnalyze = "\(start)\n\n[...content truncated...]\n\n\(end)"
        } else {
            contentToAnalyze = content
        }

        // Use centralized prompt template
        let availableNotesList = otherNotes.map { "\($0.title) (\($0.path))" }
        let template = PromptTemplate.orphanAnalysis(
            title: orphan.title,
            availableNotes: availableNotesList
        )

        // Use multi-agent if enabled and available
        let response: String
        if useMultiAgent && multiAgent.canUseMultiAgent {
            Log.info("SmartConnections: Using multi-agent for orphan analysis", category: .ai)
            let result = try await multiAgent.analyze(
                content: contentToAnalyze,
                task: .identifyOrphans
            )
            response = result.mergedResponse
        } else {
            response = try await lmService.analyzeContent(contentToAnalyze, prompt: template.systemPrompt)
        }

        // Parse response
        guard let data = response.data(using: .utf8),
              let suggestions = try? JSONDecoder().decode([AIConnectionSuggestion].self, from: data) else {
            return []
        }

        return suggestions.compactMap { suggestion -> ConnectionSuggestion? in
            guard suggestion.strength >= minimumStrengthThreshold else { return nil }

            let connection = NoteConnection(
                sourceNotePath: orphan.path,
                targetNotePath: suggestion.target,
                connectionType: suggestion.connectionType,
                strength: suggestion.strength,
                reason: suggestion.reason,
                keywords: suggestion.keywords ?? []
            )

            return ConnectionSuggestion(suggestedConnection: connection)
        }
    }

    // MARK: - Clear Data

    func clearAllData() {
        Log.warning("Clearing all Smart Connections data", category: .vault)
        persistence.clearAllData()
        vaultGraph = nil
        pendingSuggestions = []
        feedbackMetrics = FeedbackMetrics()
        learningWeights = LearningWeights()
        analysisProgress = nil
        Log.info("All Smart Connections data cleared", category: .vault)
    }
}


Now let me search for the AnalysisProgress struct definition:


Bash

find /Users/neog/Apps/iOS/ObsidianViewer-iOS -name "*.swift" -type f

Find all Swift files in the project


Let me search for the AnalysisProgress struct definition in the models:


Found 1 file
ObsidianViewer-iOS/Models/GraphModels.swift


Now let me read the GraphModels file to see the AnalysisProgress struct: