scripts/win/ai/prompt.ps1

# Borg AI prompt runner — config-driven (GPT/Claude)
# - Flat config (inline API keys)
# - File loading + chunking + basic redaction
# - Anthropic Web Search auto-enabled when Engine=claude (can be disabled via AI.ClaudeWebSearchEnabled=false)

param(
    [Parameter(Mandatory, Position = 0)]
    [string]$Prompt,

    [Alias('f')]
    [string[]]$Files
)

. "$env:BORG_ROOT\config\globalfn.ps1"

function Fail($msg) { Write-Host " $msg" -ForegroundColor Red; exit 1 }

# ─────────────────────────────────────────────────────────────────────────────
# Normalize Files (wrapper safety)
# ─────────────────────────────────────────────────────────────────────────────
if ($Files) {
    if ($Files.Count -eq 1 -and ($Files[0] -match ',')) {
        $Files = $Files[0].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
    }
    if ($Files.Count -eq 1 -and ($Files[0] -match '\s') -and -not (Test-Path $Files[0])) {
        $Files = $Files[0] -split '\s+' | Where-Object { $_ -ne '' }
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Load AI config (flat)
# ─────────────────────────────────────────────────────────────────────────────
$engine = (GetBorgStoreValue -Chapter AI -Key Engine)
$systemText = (GetBorgStoreValue -Chapter AI -Key SystemPrompt)
if ([string]::IsNullOrWhiteSpace($engine)) { $engine = 'gpt' }
if ([string]::IsNullOrWhiteSpace($systemText)) { $systemText = 'You are a concise senior engineer.' }

# Current local time context (just informational for the model)
$now = Get-Date
$tzLabel = [TimeZoneInfo]::Local.Id
$systemText = @"
$systemText
 
Current date/time: $($now.ToString("dddd, MMMM dd, yyyy HH:mm")) ($tzLabel).
"@


function Get-AI {
    param([string]$k)
    switch ($engine.ToLower()) {
        'gpt' { return GetBorgStoreValue -Chapter AI -Key ("Gpt$k") }
        'claude' { return GetBorgStoreValue -Chapter AI -Key ("Claude$k") }
        default { Fail "Unsupported Engine '$engine'. Use 'gpt' or 'claude'." }
    }
}

$baseUrl = (Get-AI 'BaseUrl')
$model = (Get-AI 'Model')
$tempStr = (Get-AI 'Temperature')
$maxStr = (Get-AI 'MaxOutputTokens')
$apiKey = (Get-AI 'ApiKey')

if ([string]::IsNullOrWhiteSpace($baseUrl)) {
    $baseUrl = if ($engine -ieq 'claude') { 'https://api.anthropic.com' } else { 'https://api.openai.com/v1' }
}
if ([string]::IsNullOrWhiteSpace($model)) { Fail "No model configured for engine '$engine'." }
if ([string]::IsNullOrWhiteSpace($apiKey)) {
    $engineCap = $engine.Substring(0, 1).ToUpper() + $engine.Substring(1)
    $keyProp = "${engineCap}ApiKey"
    Fail "API key not found. Add '$keyProp' under AI in store.json."
}

$temperature = if ($tempStr -is [double]) { [double]$tempStr } else { 0.2 }
$maxOut = if ($maxStr -is [int]) { [int]$maxStr }    else { 1200 }

# Files / safety
$maxUploadMB = [int](GetBorgStoreValue -Chapter AI -Key MaxUploadMB)
$allowedExt = (GetBorgStoreValue -Chapter AI -Key AllowedExtensions)
$chunkStrategy = (GetBorgStoreValue -Chapter AI -Key ChunkStrategy)
$tokensPerChunk = [int](GetBorgStoreValue -Chapter AI -Key TokensPerChunk)
$overlapTokens = [int](GetBorgStoreValue -Chapter AI -Key OverlapTokens)
$stripSecrets = [bool](GetBorgStoreValue -Chapter AI -Key StripSecrets)
$redactKeys = (GetBorgStoreValue -Chapter AI -Key RedactKeys)

# Anthropic Web Search toggle (defaults to true if missing)
$claudeWebSearchEnabled = $true
try { $val = GetBorgStoreValue -Chapter AI -Key ClaudeWebSearchEnabled; if ($null -ne $val) { $claudeWebSearchEnabled = [bool]$val } } catch {}

# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
function Show-HttpError([System.Management.Automation.ErrorRecord]$err) {
    try {
        if ($err.Exception.Response -and $err.Exception.Response.GetResponseStream) {
            $sr = New-Object System.IO.StreamReader($err.Exception.Response.GetResponseStream())
            $body = $sr.ReadToEnd()
            if ($body) { Write-Host " API error body: $body" -ForegroundColor Yellow }
        }
        elseif ($err.ErrorDetails -and $err.ErrorDetails.Message) {
            Write-Host " API error details: $($err.ErrorDetails.Message)" -ForegroundColor Yellow
        }
    }
    catch { }
}

function Apply-Redactions([string]$text) {
    $san = $text
    if ($stripSecrets) {
        $san = [regex]::Replace($san, '(^|\s)(?:(?<k>[A-Za-z0-9_]*(key|secret|token|password)[A-Za-z0-9_]*))\s*=\s*.+$', '${k}=[REDACTED]', 'IgnoreCase,Multiline')
        $san = [regex]::Replace($san, '"(?<k>(ApiKey|Password|Token|Secret|AccessToken))"\s*:\s*"[^"]*"', '"${k}":"[REDACTED]"', 'IgnoreCase')
    }
    if ($redactKeys) {
        foreach ($rk in $redactKeys) {
            $rkEsc = [regex]::Escape($rk)
            $san = [regex]::Replace($san, '"' + $rkEsc + '"\s*:\s*"[^"]*"', '"' + $rk + '":"[REDACTED]"', 'IgnoreCase')
        }
    }
    return $san
}

function Chunk-Text([string]$text) {
    $strategy = ($chunkStrategy ?? 'byTokens').ToLowerInvariant()
    switch ($strategy) {
        'bybytes' {
            $max = 4800
            if ($text.Length -le $max) { return , $text }
            $chunks = @()
            for ($i = 0; $i -lt $text.Length; $i += $max) {
                $len = [Math]::Min($max, $text.Length - $i)
                $chunks += $text.Substring($i, $len)
            }
            return , $chunks
        }
        'bylines' {
            $per = 300
            $lines = $text -split "`r?`n"
            $chunks = @()
            for ($i = 0; $i -lt $lines.Count; $i += $per) {
                $chunks += ($lines[$i..([Math]::Min($i + $per - 1, $lines.Count - 1))] -join "`n")
            }
            return , $chunks
        }
        default {
            $tPer = ($tokensPerChunk -gt 0) ? $tokensPerChunk : 1200
            $ov = ($overlapTokens -ge 0) ? $overlapTokens  : 150
            $cPer = $tPer * 4
            $ovC = $ov * 4
            if ($text.Length -le $cPer) { return , $text }
            $chunks = @(); $i = 0
            while ($i -lt $text.Length) {
                $len = [Math]::Min($cPer, $text.Length - $i)
                $chunks += $text.Substring($i, $len)
                if ($i + $len -ge $text.Length) { break }
                $i += ($len - [Math]::Min($ovC, $len))
            }
            return , $chunks
        }
    }
}

function Validate-And-ReadFiles([string[]]$files) {
    if (-not $files -or $files.Count -eq 0) { return @() }
    $allowed = @()
    if ($allowedExt) { $allowed = @($allowedExt | ForEach-Object { $_.ToLowerInvariant() }) }
    $limitMB = ($maxUploadMB -gt 0) ? $maxUploadMB : 25
    $maxBytes = $limitMB * 1MB
    $res = @()

    foreach ($f in $files) {
        $resolved = Resolve-Path -Path $f -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Path
        if (-not (Test-Path $resolved -PathType Leaf)) { Fail "File not found: $f" }
        $ext = [IO.Path]::GetExtension($resolved).ToLowerInvariant()
        if ($allowed.Count -gt 0 -and $allowed -notcontains $ext) {
            Fail "Extension '$ext' not allowed. Allowed: $($allowed -join ', ')"
        }
        $info = Get-Item $resolved
        if ($info.Length -gt $maxBytes) {
            Fail "File '$resolved' exceeds MaxUploadMB=$limitMB (size: $([string]::Format('{0:N0}', $info.Length)) bytes)"
        }
        $raw = Get-Content -Raw -Encoding UTF8 $resolved
        $safe = Apply-Redactions $raw
        $parts = Chunk-Text $safe
        $i = 0
        foreach ($p in $parts) {
            $i++
            $res += @"
===== BEGIN FILE: $([IO.Path]::GetFileName($resolved)) (part $i of $($parts.Count)) =====
$p
===== END FILE: $([IO.Path]::GetFileName($resolved)) (part $i of $($parts.Count)) =====
"@
.Trim()
        }
    }
    return , $res
}

# ─────────────────────────────────────────────────────────────────────────────
# Build user message (with optional file context)
# ─────────────────────────────────────────────────────────────────────────────
$fileBlocks = Validate-And-ReadFiles -files $Files
$userMessage = if ($fileBlocks.Count -gt 0) {
    @"
# Task
$Prompt
 
# Context (files)
$($fileBlocks -join "`n`n")
"@

}
else { $Prompt }

# ─────────────────────────────────────────────────────────────────────────────
# Provider calls
# ─────────────────────────────────────────────────────────────────────────────
function Invoke-OpenAI([string]$baseUrl, [string]$apiKey, [string]$model, [string]$system, [string]$user, [double]$temp, [int]$maxTok) {
    $url = ($baseUrl.TrimEnd('/')) + "/chat/completions"
    $headers = @{ "Authorization" = "Bearer $apiKey"; "Content-Type" = "application/json" }
    $body = @{
        model       = $model
        messages    = @(
            @{ role = "system"; content = $system },
            @{ role = "user"; content = $user }
        )
        temperature = $temp
        max_tokens  = $maxTok
    }
    $json = $body | ConvertTo-Json -Depth 10
    Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body $json -TimeoutSec 120
}

function Invoke-Anthropic([string]$baseUrl, [string]$apiKey, [string]$model, [string]$system, [string]$user, [double]$temp, [int]$maxTok, [bool]$enableWebSearch) {
    $url = ($baseUrl.TrimEnd('/')) + "/v1/messages"
    $headers = @{
        "x-api-key"         = $apiKey
        "anthropic-version" = "2023-06-01"
        "content-type"      = "application/json"
    }
    
    if ($enableWebSearch) {
        # Required beta header for Anthropic Web Search
        $headers["anthropic-beta"] = "web-search-2025-03-05"
    }

    $body = @{
        model       = $model
        system      = $system
        max_tokens  = $maxTok
        temperature = $temp
        messages    = @(@{ role = "user"; content = @(@{ type = "text"; text = $user }) })
    }

    if ($enableWebSearch) {
        # Enable Claude's native web search
        $body.tools = @(@{ 
            type = "web_search_20250305"
            name = "web_search"  # ← REQUIRED field
            max_uses = 5         # Optional: limit searches
        })
        $body.tool_choice = @{ type = "auto" }
    }

    $json = $body | ConvertTo-Json -Depth 10
    Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body $json -TimeoutSec 120
}

function Call-LLM([string]$sys, [string]$usr, [int]$maxTokHere) {
    switch ($engine.ToLower()) {
        'gpt' {
            $r = Invoke-OpenAI -baseUrl $baseUrl -apiKey $apiKey -model $model -system $sys -user $usr -temp $temperature -maxTok $maxTokHere
            return $r.choices[0].message.content
        }
        'claude' {
            $r = Invoke-Anthropic -baseUrl $baseUrl -apiKey $apiKey -model $model -system $sys -user $usr -temp $temperature -maxTok $maxTokHere -enableWebSearch:$claudeWebSearchEnabled
            
            # Extract all text content blocks from the response
            if ($r.content -and $r.content.Count -gt 0) {
                $textParts = @()
                foreach ($block in $r.content) {
                    if ($block.type -eq 'text' -and $block.text) {
                        $textParts += $block.text
                    }
                }
                if ($textParts.Count -gt 0) {
                    return ($textParts -join "`n")
                }
            }
            return ""
        }
        default { Fail "Unsupported Engine '$engine'." }
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Execute and print
# ─────────────────────────────────────────────────────────────────────────────
try {
    Write-Host ("Using {0} (web-search:{1}): {2}" -f $engine, ($(if ($engine -eq 'claude') { $claudeWebSearchEnabled } else { $false })), $baseUrl) -ForegroundColor DarkGray
    $text = Call-LLM -sys $systemText -usr $userMessage -maxTokHere $maxOut
    if ([string]::IsNullOrWhiteSpace($text)) {
        Write-Host " No text returned by engine '$engine'." -ForegroundColor Yellow
        exit 2
    }
    Write-Host "`n$text"
    if (Get-Command -Name CopyToClipboard -ErrorAction SilentlyContinue) {
        if (CopyToClipboard $text) { Write-Host "`n (Copied to clipboard)" -ForegroundColor DarkGray }
    }
}
catch {
    Show-HttpError $_
    Fail ("API call failed: " + $_.Exception.Message)
}