Private/UsageRegistry.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

# UsageID registry loader + template renderer (t/1261).
# Dot-sourced by AITriad.psm1 — do NOT export.
#
# PS mirror of the TypeScript lib/ai-client/usageRegistry.ts (t/1259/t/1260).
# Reads ai-usages.json at the code repo root, resolves _extends chains, and
# renders {{var}} templates. Consumers pass a UsageID + values hashtable and
# get back a resolved parameter bag suitable for splatting to Invoke-AIApi.

$script:UsageRegistryCache          = $null
$script:UsageRegistryCacheTimestamp = $null
$script:UsageRegistryCachePath      = $null

function Get-UsageRegistryPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    $root = Get-CodeRoot
    return Join-Path $root 'ai-usages.json'
}

function Get-UsageRegistry {
    <#
    .SYNOPSIS
        Loads (or returns cached) ai-usages.json content as a PSCustomObject.
    .DESCRIPTION
        Reads $repoRoot/ai-usages.json, caches parsed structure. Invalidates
        on file mtime change. Returns the raw parsed object; consumers should
        use Get-UsageConfig for per-id lookup with _extends resolution.
    .PARAMETER Force
        Bypass the cache and re-read from disk.
    .PARAMETER Path
        Override the source file path. Defaults to repo-root/ai-usages.json.
    #>

    [CmdletBinding()]
    param(
        [switch]$Force,
        [string]$Path
    )
    Set-StrictMode -Version Latest

    if ($Path) { $p = $Path } else { $p = Get-UsageRegistryPath }

    if (-not (Test-Path $p)) {
        throw (New-ActionableError -PassThru `
            -Goal 'Load ai-usages registry' `
            -Problem "ai-usages.json not found at $p" `
            -Location 'Get-UsageRegistry' `
            -NextSteps @(
                'Verify the code repo root contains ai-usages.json',
                'Check Get-CodeRoot resolves the expected path',
                'Restore ai-usages.json from git if the file was deleted'
            ))
    }

    $mtime = (Get-Item $p).LastWriteTimeUtc
    if (-not $Force -and $script:UsageRegistryCache -and $script:UsageRegistryCacheTimestamp -eq $mtime -and $script:UsageRegistryCachePath -eq $p) {
        return $script:UsageRegistryCache
    }
    try {
        $raw = Get-Content -Raw -Path $p -Encoding utf8
        $parsed = $raw | ConvertFrom-Json
    } catch {
        throw (New-ActionableError -PassThru `
            -Goal 'Parse ai-usages registry' `
            -Problem "Failed to parse ai-usages.json: $($_.Exception.Message)" `
            -Location 'Get-UsageRegistry' `
            -NextSteps @(
                'Validate JSON with: Get-Content ai-usages.json | ConvertFrom-Json',
                'Check for trailing commas or unbalanced brackets'
            ))
    }
    $script:UsageRegistryCache          = $parsed
    $script:UsageRegistryCacheTimestamp = $mtime
    $script:UsageRegistryCachePath      = $p
    return $parsed
}

function Clear-UsageRegistryCache {
    [CmdletBinding()]
    param()
    $script:UsageRegistryCache          = $null
    $script:UsageRegistryCacheTimestamp = $null
    $script:UsageRegistryCachePath      = $null
}

function Get-UsageConfig {
    <#
    .SYNOPSIS
        Resolves a UsageID to its fully-materialized config with _extends chain applied.
    .DESCRIPTION
        Walks the _extends chain (single-level per convention — cycles refused
        with an ActionableError). Child fields override parent fields. Skips
        the top-level _schema_version and _doc keys.
 
        Returns a hashtable with keys matching the on-disk schema (model,
        temperature, maxTokens, timeoutMs, jsonMode, responseSchema,
        systemMessage, systemMessageTemplate, message, messageTemplate,
        tools, tags, description).
    .PARAMETER UsageId
        The usage identifier (e.g., 'enrichment.metadata-extraction').
    .PARAMETER Registry
        Optional pre-loaded registry (from Get-UsageRegistry). Loaded lazily otherwise.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$UsageId,

        [object]$Registry
    )
    Set-StrictMode -Version Latest

    if (-not $Registry) { $Registry = Get-UsageRegistry }
    if (-not $Registry.PSObject.Properties[$UsageId]) {
        throw (New-ActionableError -PassThru `
            -Goal 'Resolve usage config' `
            -Problem "UsageID '$UsageId' not found in ai-usages.json" `
            -Location 'Get-UsageConfig' `
            -NextSteps @(
                'Check spelling: Invoke-AIByUsage tab-completes UsageIDs',
                'List available usages with: (Get-UsageRegistry).PSObject.Properties.Name | Where-Object { $_ -notmatch ''^_'' }',
                "Add a new entry to ai-usages.json for '$UsageId'"
            ))
    }

    # Walk _extends chain. Guard against cycles with a bounded depth + visited set.
    $chain = [System.Collections.Generic.List[string]]::new()
    $visited = [System.Collections.Generic.HashSet[string]]::new()
    $cursor = $UsageId
    $max = 8
    while ($cursor -and $chain.Count -lt $max) {
        if (-not $visited.Add($cursor)) {
            throw (New-ActionableError -PassThru `
                -Goal 'Resolve usage config' `
                -Problem "_extends cycle detected: $($chain -join ' → ') → $cursor" `
                -Location 'Get-UsageConfig' `
                -NextSteps @("Break the cycle in ai-usages.json — remove _extends from one of the entries"))
        }
        $chain.Add($cursor)
        $node = $Registry.$cursor
        if ($node.PSObject.Properties['_extends']) {
            $cursor = [string]$node._extends
        } else {
            $cursor = $null
        }
    }
    if ($chain.Count -ge $max) {
        throw (New-ActionableError -PassThru `
            -Goal 'Resolve usage config' `
            -Problem "_extends chain exceeded max depth ($max)" `
            -Location 'Get-UsageConfig' `
            -NextSteps @('Flatten the extends chain in ai-usages.json'))
    }

    # Merge parent → child. Iterate from the deepest ancestor down so children override.
    $merged = @{}
    for ($i = $chain.Count - 1; $i -ge 0; $i--) {
        $entry = $Registry.($chain[$i])
        foreach ($prop in $entry.PSObject.Properties) {
            if ($prop.Name -eq '_extends') { continue }
            $merged[$prop.Name] = $prop.Value
        }
    }
    return $merged
}

function Convert-UsageTemplate {
    <#
    .SYNOPSIS
        Renders a template string by substituting {{key}} placeholders from a values hashtable.
    .DESCRIPTION
        Missing placeholders throw an ActionableError so silent no-op substitution
        can't hide bugs. Empty template returns empty string. Non-string values
        are coerced via [string].
    .PARAMETER Template
        The template string with {{var}} placeholders.
    .PARAMETER Values
        Hashtable mapping placeholder names to values.
    .PARAMETER UsageIdContext
        Optional UsageID for improved error context.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Template,

        [Parameter(Mandatory)]
        [hashtable]$Values,

        [string]$UsageIdContext = ''
    )
    Set-StrictMode -Version Latest

    if ([string]::IsNullOrEmpty($Template)) { return '' }

    $placeholders = [regex]::Matches($Template, '\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}')
    $missing = [System.Collections.Generic.List[string]]::new()
    foreach ($m in $placeholders) {
        $name = $m.Groups[1].Value
        if (-not $Values.ContainsKey($name)) {
            if (-not $missing.Contains($name)) { $missing.Add($name) }
        }
    }
    if ($missing.Count -gt 0) {
        $ctx = if ($UsageIdContext) { " for UsageID '$UsageIdContext'" } else { '' }
        throw (New-ActionableError -PassThru `
            -Goal 'Render usage template' `
            -Problem "Missing template value(s)${ctx}: $($missing -join ', ')" `
            -Location 'Convert-UsageTemplate' `
            -NextSteps @(
                'Add the missing keys to the -Values hashtable passed to Invoke-AIByUsage',
                'Or update ai-usages.json to remove the unused placeholder(s)'
            ))
    }

    return [regex]::Replace($Template, '\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}', {
        param($m)
        $name = $m.Groups[1].Value
        return [string]$Values[$name]
    })
}