workflows/default/systems/ui/modules/ReferenceCache.psm1
|
<# .SYNOPSIS Reference cache for prompt file cross-references .DESCRIPTION Builds and manages a cache of inter-file references across .bot/recipes/ directories. Provides file content retrieval with reference resolution. Extracted from server.ps1 for modularity. #> $script:Config = @{ BotRoot = $null ProjectRoot = $null } function Initialize-ReferenceCache { param( [Parameter(Mandatory)] [string]$BotRoot, [Parameter(Mandatory)] [string]$ProjectRoot ) $script:Config.BotRoot = $BotRoot $script:Config.ProjectRoot = $ProjectRoot } function Get-CacheLocation { $projectRoot = $script:Config.ProjectRoot $projectHash = [System.BitConverter]::ToString( [System.Security.Cryptography.MD5]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($projectRoot) ) ).Replace("-", "").Substring(0, 8) $cachePath = Join-Path ([System.IO.Path]::GetTempPath()) ".bot-ui-cache" $projectHash if (-not (Test-Path $cachePath)) { New-Item -Path $cachePath -ItemType Directory -Force | Out-Null } return $cachePath } function Test-CacheValidity { $botRoot = $script:Config.BotRoot $cacheFile = Join-Path (Get-CacheLocation) "references.json" if (-not (Test-Path $cacheFile)) { return $false } try { $cache = Get-Content $cacheFile -Raw | ConvertFrom-Json # Check if any files have been modified foreach ($fileEntry in $cache.file_mtimes.PSObject.Properties) { $filePath = Join-Path $botRoot $fileEntry.Name if (Test-Path -LiteralPath $filePath) { $currentMtime = (Get-Item -LiteralPath $filePath).LastWriteTimeUtc.ToString("yyyy-MM-ddTHH:mm:ssZ") if ($currentMtime -ne $fileEntry.Value) { return $false } } } # Cache is valid if less than 24 hours old $cacheAge = (Get-Date) - [DateTime]::Parse($cache.generated_at) return $cacheAge.TotalHours -lt 24 } catch { return $false } } function Clear-ReferenceCache { $cacheFile = Join-Path (Get-CacheLocation) "references.json" if (Test-Path $cacheFile) { Remove-Item $cacheFile -Force } return @{ success = $true message = "Cache cleared" } } # Helper: Get type from directory (generates 3-letter short type) function Get-TypeFromDir { param([string]$Dir) return $Dir.Substring(0, [Math]::Min(3, $Dir.Length)) } # Helper: Get type from path (extracts directory and generates short type) function Get-TypeFromPath { param( [string]$Path, [string[]]$Directories = @() ) if ($Path -match '^([^/]+)/') { $dir = $matches[1] return Get-TypeFromDir -Dir $dir } return 'unk' } # Helper: Parse reference (dynamic - extracts type from path) function Parse-Reference { param( [string]$LinkPath, [string]$CurrentFile, [hashtable]$AllFiles ) $filename = Split-Path $LinkPath -Leaf $name = [System.IO.Path]::GetFileNameWithoutExtension($filename) $type = 'unk' $relativePath = $filename # Match patterns like .bot/recipes/TYPE/subpath/file.md or ../TYPE/subpath/file.md if ($LinkPath -match '(?:recipes/)?(\w+)/(.+\.md)$') { $dir = $matches[1] $type = Get-TypeFromDir -Dir $dir $relativePath = $matches[2] } # If no directory found, try to infer from current file's directory elseif ($CurrentFile -match '^([^/]+)/') { $type = Get-TypeFromDir -Dir $matches[1] } return @{ type = $type file = $relativePath name = $name } } # Helper: Find target path (dynamic - derives directory from short type) function Find-TargetPath { param( [hashtable]$Reference, [hashtable]$AllFiles ) $shortType = $Reference.type # Find matching directory by checking if its short type matches $matchingDir = $null foreach ($key in $AllFiles.Keys) { if ($key -match '^([^/]+)/') { $dir = $matches[1] if ($dir.Substring(0, [Math]::Min(3, $dir.Length)) -eq $shortType) { $matchingDir = $dir break } } } if ($matchingDir) { # Try direct path first $targetPath = "$matchingDir/$($Reference.file)" if ($AllFiles.ContainsKey($targetPath)) { return $targetPath } # Try with subdirectories foreach ($key in $AllFiles.Keys) { $escapedFile = [regex]::Escape($Reference.file) if ($key -match "^$matchingDir/.*$escapedFile$") { return $key } } } return $null } function Build-ReferenceCache { $botRoot = $script:Config.BotRoot $projectRoot = $script:Config.ProjectRoot Write-BotLog -Level Debug -Message "" Write-Status "Building reference cache..." -Type Process $cache = @{ generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") project_root = $projectRoot file_mtimes = @{} references = @{} } # Dynamically discover directories under .bot/recipes/ $promptsDir = Join-Path $botRoot "recipes" $dirs = @() if (Test-Path $promptsDir) { $dirs = @(Get-ChildItem -Path $promptsDir -Directory | ForEach-Object { $_.Name }) } $allFiles = @{} # First pass: collect all files foreach ($dir in $dirs) { $dirPath = Join-Path $botRoot "recipes\$dir" if (Test-Path $dirPath) { $mdFiles = Get-ChildItem -Path $dirPath -Filter "*.md" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\archived\\' } foreach ($file in $mdFiles) { $relativePath = "$dir/" + $file.FullName.Replace("$dirPath\", "").Replace("\", "/") $allFiles[$relativePath] = $file.FullName $cache.file_mtimes[$relativePath] = $file.LastWriteTimeUtc.ToString("yyyy-MM-ddTHH:mm:ssZ") } } } # Second pass: parse references foreach ($entry in $allFiles.GetEnumerator()) { $relativePath = $entry.Key $fullPath = $entry.Value $content = Get-Content -Path $fullPath -Raw $references = @() # Parse markdown links: [text](path.md) $mdLinkPattern = '\[([^\]]+)\]\(([^\)]+\.md)\)' $regexMatches = [regex]::Matches($content, $mdLinkPattern) foreach ($m in $regexMatches) { if ($null -ne $m -and $null -ne $m.Groups -and $m.Groups.Count -gt 2) { $linkPath = $m.Groups[2].Value $references += Parse-Reference -LinkPath $linkPath -CurrentFile $relativePath -AllFiles $allFiles } } # Parse agent directives: @.bot/recipes/agents/name.md $agentPattern = '@\.bot/recipes/(\w+)/([^\s]+\.md)' $regexMatches = [regex]::Matches($content, $agentPattern) foreach ($m in $regexMatches) { if ($null -ne $m -and $null -ne $m.Groups -and $m.Groups.Count -gt 2) { $dir = $m.Groups[1].Value $refFullPath = $m.Groups[2].Value $filename = Split-Path $refFullPath -Leaf $references += @{ type = Get-TypeFromDir -Dir $dir file = $refFullPath name = [System.IO.Path]::GetFileNameWithoutExtension($filename) } } } # Parse path references: .bot/recipes/standards/global/file.md $pathPattern = '\.bot/recipes/(\w+)/([^\s]+\.md)' $regexMatches = [regex]::Matches($content, $pathPattern) foreach ($m in $regexMatches) { if ($null -ne $m -and $null -ne $m.Groups -and $m.Groups.Count -gt 2) { $dir = $m.Groups[1].Value $refFullPath = $m.Groups[2].Value $filename = Split-Path $refFullPath -Leaf $references += @{ type = Get-TypeFromDir -Dir $dir file = $refFullPath name = [System.IO.Path]::GetFileNameWithoutExtension($filename) } } } # Remove duplicates $uniqueRefs = @{} foreach ($ref in $references) { $key = "$($ref.type):$($ref.file)" $uniqueRefs[$key] = $ref } $cache.references[$relativePath] = @{ references = @($uniqueRefs.Values) referenced_by = @() } } # Third pass: build reverse references $refKeys = @($cache.references.Keys) foreach ($sourcePath in $refKeys) { $entry = $cache.references[$sourcePath] if ($null -eq $entry) { continue } $refs = $entry.references if ($null -eq $refs) { continue } foreach ($ref in @($refs)) { if ($null -eq $ref) { continue } try { # Find the target file $targetPath = Find-TargetPath -Reference $ref -AllFiles $allFiles if ($targetPath -and $cache.references.ContainsKey($targetPath)) { $sourceType = Get-TypeFromPath -Path $sourcePath -Directories $dirs $sourceRelativePath = $sourcePath -replace '^[^/]+/', '' if ($null -eq $cache.references[$targetPath].referenced_by) { $cache.references[$targetPath].referenced_by = @() } $cache.references[$targetPath].referenced_by += @{ type = $sourceType file = $sourceRelativePath name = [System.IO.Path]::GetFileNameWithoutExtension($sourceRelativePath) } } } catch { Write-Status "Error processing reference for $($ref.file): $_" -Type Warn } } } # Save cache $cacheFile = Join-Path (Get-CacheLocation) "references.json" $cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cacheFile -Force Write-Status "Reference cache built with $($cache.references.Count) files" -Type Success $cache.references.Keys | Where-Object { $_ -like "*write-spec*" } | ForEach-Object { Write-Phosphor " Cached: $_" -Color Bezel } return $cache } function Get-FileWithReferences { param( [string]$Type, [string]$Filename ) $botRoot = $script:Config.BotRoot # Dynamically find directory that matches the short type $promptsDir = Join-Path $botRoot "recipes" $matchingDir = $null if (Test-Path $promptsDir) { $allDirs = Get-ChildItem -Path $promptsDir -Directory foreach ($dir in $allDirs) { $shortType = $dir.Name.Substring(0, [Math]::Min(3, $dir.Name.Length)) if ($shortType -eq $Type) { $matchingDir = $dir.Name break } } } # Fallback: workflow-scoped types (e.g. "iwg-bs-scoring_age" → workflows/iwg-bs-scoring/recipes/agents) if (-not $matchingDir -and $Type -match '_') { $lastUnderscore = $Type.LastIndexOf('_') $wfName = $Type.Substring(0, $lastUnderscore) $subType = $Type.Substring($lastUnderscore + 1) $wfPromptsDir = Join-Path $botRoot "workflows\$wfName\recipes" if (Test-Path $wfPromptsDir) { $wfDirs = Get-ChildItem -Path $wfPromptsDir -Directory foreach ($dir in $wfDirs) { $shortType = $dir.Name.Substring(0, [Math]::Min(3, $dir.Name.Length)) if ($shortType -eq $subType) { $matchingDir = "__wf__$wfName/$($dir.Name)" break } } } } if (-not $matchingDir) { return @{ success = $false error = "Invalid type: $Type" } } # Resolve the actual filesystem path if ($matchingDir -match '^__wf__(.+)/(.+)$') { $targetDir = Join-Path $botRoot "workflows\$($Matches[1])\recipes\$($Matches[2])" } else { $targetDir = Join-Path $botRoot "recipes\$matchingDir" } $filePath = Join-Path $targetDir $Filename if (-not (Test-Path -LiteralPath $filePath)) { return @{ success = $false error = "File not found: $Filename" } } # Check cache first $cacheFile = Join-Path (Get-CacheLocation) "references.json" $cache = $null if (Test-CacheValidity) { try { $cache = Get-Content $cacheFile -Raw | ConvertFrom-Json } catch { # Cache invalid, will rebuild } } # Build cache if needed if (-not $cache) { $cache = Build-ReferenceCache } # Get file content $fileContent = Get-Content -LiteralPath $filePath -Raw $relativePath = "$matchingDir/$Filename" # Get references from cache $references = @() $referencedBy = @() Write-Phosphor "Looking up: $relativePath" -Color Bezel # Handle both hashtable (from Build-ReferenceCache) and PSCustomObject (from JSON) $hasKey = $false if ($cache.references -is [hashtable]) { $hasKey = $cache.references.ContainsKey($relativePath) } elseif ($null -ne $cache.references) { $hasKey = $null -ne $cache.references.PSObject.Properties[$relativePath] } Write-Phosphor "Cache has key? $hasKey" -Color Bezel if ($hasKey) { $fileRefs = $cache.references.$relativePath if ($null -ne $fileRefs) { $refCount = if ($fileRefs.references) { @($fileRefs.references).Count } else { 0 } $refByCount = if ($fileRefs.referenced_by) { @($fileRefs.referenced_by).Count } else { 0 } Write-Status "Found refs: $refCount, refBy: $refByCount" -Type Success if ($fileRefs.references) { $references = @($fileRefs.references) } if ($fileRefs.referenced_by) { $referencedBy = @($fileRefs.referenced_by) } } } else { Write-Status "Key not found in cache!" -Type Error } return @{ success = $true name = $Filename content = $fileContent references = $references referencedBy = $referencedBy cacheAge = if ($cache.generated_at) { [int]((Get-Date) - [DateTime]::Parse($cache.generated_at)).TotalMinutes } else { 0 } } } Export-ModuleMember -Function @( 'Initialize-ReferenceCache', 'Get-CacheLocation', 'Test-CacheValidity', 'Clear-ReferenceCache', 'Get-FileWithReferences', 'Build-ReferenceCache' ) |