com.github.copilot/hooks/scripts/Add-FamiliarityContext.ps1

<#
.SYNOPSIS
    PostToolUse hook that re-sends the contributor's familiarity levels after a
    compaction, and as a backstop on a new turn or 5 minutes later.
.DESCRIPTION
    No host reruns SessionStart after a compaction, and the Copilot SDK host and
    Copilot CLI drop its context when they compact. The PreCompact hook counts
    each compaction in session-<key>.familiarity.json beside the session clock;
    this hook answers it on the next successful tool call (Decision record 0028).
    VS Code Local runs no PreCompact for a manual or a background compaction, so
    the hook also re-sends the levels as a backstop on the first successful tool
    call after a turn closes, or more than 5 minutes after the last injection
    once the first turn has closed (ruling A12). Only a session whose SessionStart
    injected levels is armed for the backstop: it seeds lastInjectionUtc.
 
    The registration file ~/.copilot/hooks/contributor-profile.json loads this
    hook only while a Contributor profile on this machine has an entry that is
    on and rates a Knowledge area, and it runs after every successful tool call.
    The common path therefore stays short and writes nothing: it finds
    session_id near the start of the payload, where both hosts put it ahead of
    every tool field, reads the small state file and, in an armed session, the
    turn count of the session clock, and exits when nothing is due.
 
    When a re-send is due, it parses the payload for cwd and rechecks the
    profile, so an opt-out or a deletion takes effect at once. It then takes the
    state lock that PreCompact also takes, re-reads the state, decides again,
    records the injection, and only then emits the matched levels under both
    host keys, so an injection that could not be recorded is never sent and
    cannot re-fire. The sentence carries no unrated count and ends with a suffix
    that suppresses every offer for the rest of the session. The compaction it
    answers is recorded by compare-and-set, so a newer compaction is never lost.
    Backstop re-sends stop once the session has received 12,000 characters of
    calibration text; compaction re-sends continue up to 60,000, above which
    nothing is sent.
.PARAMETER InputJson
    Hook payload as JSON. Defaults to reading standard input. Tests pass the
    payload directly so they do not depend on redirected input.
.PARAMETER ClockRoot
    Directory holding the session clocks and the calibration state. Defaults to
    the per-user application data location. Tests override it.
.NOTES
    Never emits decision and never exits 2: every path exits 0, so this hook can
    only add context, never block or change a tool call. It reads the session
    clock and never writes it. Writes one JSON object that serves both hosts: a
    top-level additionalContext, which the Copilot SDK host and Copilot CLI
    read, and hookSpecificOutput.additionalContext, which the VS Code Local
    harness reads.
#>


[CmdletBinding()]
param(
    [Parameter()]
    [AllowEmptyString()]
    [AllowNull()]
    [string]$InputJson,

    [Parameter()]
    [AllowEmptyString()]
    [AllowNull()]
    [string]$ClockRoot
)

function Get-SessionClockPath {
    <#
        Resolves the clock file for a session. Duplicated verbatim in
        Add-SessionContext.ps1, Write-SessionClose.ps1, and
        Write-CompactionCheckpoint.ps1: VS Code launches each hook by its own
        path, so a shared helper would need the same fragile path probing that
        hooks.json already carries. Every copy must derive the same name from
        the same payload, so change them together.
    #>

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

        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$WorkingDirectory,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Root
    )

    if ([string]::IsNullOrWhiteSpace($Root)) {
        # Per-user by construction. The temp directory is world-writable on
        # Linux, where a predictable name invites another local account to
        # pre-create the path.
        $Root = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)

        if ([string]::IsNullOrWhiteSpace($Root)) {
            $Root = [IO.Path]::GetTempPath()
        }

        $Root = [IO.Path]::Combine($Root, 'CopilotAtelier', 'sessions')
    }

    # The payload supplies this value, so it becomes a path component only after
    # every character that could traverse a directory is gone. -creplace, because
    # -replace ignores case and so lets U+0130 and U+212A through the class.
    $key = ($SessionId -creplace '[^A-Za-z0-9._-]', '')

    if ($key.Length -gt 64) {
        $key = $key.Substring(0, 64)
    }

    if ([string]::IsNullOrWhiteSpace($key)) {
        # No session id: fall back to the workspace so two concurrent windows do
        # not share one clock.
        $seed = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { 'default' } else { $WorkingDirectory }
        $sha = [Security.Cryptography.SHA256]::Create()

        try {
            $digest = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed))
        } finally {
            $sha.Dispose()
        }

        $key = 'cwd-' + [BitConverter]::ToString($digest[0..7]).Replace('-', '').ToLowerInvariant()
    }

    return [IO.Path]::Combine($Root, "session-$key.json")
}

function ConvertTo-AsciiJson {
    <#
        Writes every character of a JSON text outside ASCII as \uXXXX. Windows
        PowerShell 5.1 encodes standard output in the console's OEM code page, and
        that encoding's best fit turns characters such as U+201C, U+02BC, and U+2032
        into a bare ASCII quote. In the workspace path that breaks the JSON and
        lets the rest of a folder name become context; in a quoted path it ends the
        string early, after the quoting. Quoting cannot cover what happens later,
        but a JSON text that is pure ASCII reads the same in every code page and in
        UTF-8.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Json
    )

    return [regex]::Replace($Json, '[^\x00-\x7F]', { param($match) '\u{0:x4}' -f [int][char]$match.Value })
}

function Read-CalibrationState {
    <#
        Reads session-<key>.familiarity.json, the per-session calibration
        state. Duplicated verbatim, with Save-CalibrationState and
        Enter-CalibrationStateLock, in Add-SessionContext.ps1,
        Write-CompactionCheckpoint.ps1, and Add-FamiliarityContext.ps1, for the
        reason Get-SessionClockPath gives; change the copies together. A
        missing file reads as a fresh state and a missing field as absent:
        lastTurn and lastInjectionUtc stay $null until SessionStart seeds them,
        and characters reads 0. An unreadable file throws, unless
        -ReplaceUnreadable asks for a fresh state instead. Only these hooks
        write the file, as one flat object, so a pattern per field reads it:
        ConvertFrom-Json would load a module on the PostToolUse common path
        and double its cost.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$StatePath,

        [Parameter()]
        [switch]$ReplaceUnreadable
    )

    $fresh = [pscustomobject]@{
        Compactions = 0
        Injected = 0
        LastTurn = $null
        LastInjectionUtc = $null
        Characters = 0
    }

    if (-not [IO.File]::Exists($StatePath)) {
        return $fresh
    }

    try {
        $text = [IO.File]::ReadAllText($StatePath)

        if ($text -notmatch '\A\s*\{[^{}]*\}\s*\z') {
            throw 'The calibration state is not one flat JSON object.'
        }

        $field = @{}
        foreach ($match in [regex]::Matches($text, '"([A-Za-z]+)"\s*:\s*("[^"\\]*"|[^,}\s]+)')) {
            if (-not $field.ContainsKey($match.Groups[1].Value)) {
                $field[$match.Groups[1].Value] = $match.Groups[2].Value
            }
        }

        $state = [pscustomobject]@{
            Compactions = 0
            Injected = 0
            LastTurn = $null
            LastInjectionUtc = $null
            Characters = 0
        }

        foreach ($name in 'compactions', 'injected', 'lastTurn', 'characters') {
            if ($null -ne $field[$name] -and $field[$name] -ne 'null') {
                $state.$name = [Math]::Max(0, [int]::Parse($field[$name], [Globalization.NumberStyles]::AllowLeadingSign, [Globalization.CultureInfo]::InvariantCulture))
            }
        }

        $stamp = $field['lastInjectionUtc']
        if ($null -ne $stamp -and $stamp -ne 'null') {
            if ($stamp -notmatch '\A"(.+)"\z') {
                throw 'lastInjectionUtc is not a timestamp.'
            }

            $state.LastInjectionUtc = [datetimeoffset]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime
        }

        return $state
    } catch {
        if (-not $ReplaceUnreadable) {
            throw
        }

        Write-Debug -Message "Unreadable calibration state replaced: $($_.Exception.Message)"
        return $fresh
    }
}

function Save-CalibrationState {
    <#
        Writes every field of the calibration state with an atomic replace,
        under the state lock the caller holds. An absent lastTurn or
        lastInjectionUtc stays absent, so only SessionStart can arm the
        backstop. Duplicated verbatim; see Read-CalibrationState.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$StatePath,

        [Parameter(Mandatory = $true)]
        [pscustomobject]$State
    )

    $text = '{"schemaVersion":1,"compactions":' + [int]$State.Compactions + ',"injected":' + [int]$State.Injected

    if ($null -ne $State.LastTurn) {
        $text += ',"lastTurn":' + [int]$State.LastTurn
    }

    if ($null -ne $State.LastInjectionUtc) {
        $text += ',"lastInjectionUtc":"' + ([datetime]$State.LastInjectionUtc).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) + '"'
    }

    $text += ',"characters":' + [int]$State.Characters + '}'
    $temporary = $StatePath + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'

    try {
        [IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false))

        if ([IO.File]::Exists($StatePath)) {
            [IO.File]::Replace($temporary, $StatePath, [System.Management.Automation.Language.NullString]::Value)
        } else {
            [IO.File]::Move($temporary, $StatePath)
        }
    } finally {
        if ([IO.File]::Exists($temporary)) {
            try {
                [IO.File]::Delete($temporary)
            } catch {
                Write-Debug -Message "Temporary calibration state left behind: $($_.Exception.Message)"
            }
        }
    }
}

function Enter-CalibrationStateLock {
    <#
        Opens the lock every writer of the calibration state takes, waiting up
        to 5 seconds, and returns $null when another hook still holds it.
        Duplicated verbatim; see Read-CalibrationState.
    #>

    [CmdletBinding()]
    [OutputType([IO.FileStream])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$StatePath
    )

    $lockPath = [IO.Path]::ChangeExtension($StatePath, '.lock')
    $watch = [Diagnostics.Stopwatch]::StartNew()

    while ($true) {
        try {
            return [IO.FileStream]::new($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None, 1, [IO.FileOptions]::DeleteOnClose)
        } catch {
            if ($watch.ElapsedMilliseconds -ge 5000) {
                return $null
            }

            Start-Sleep -Milliseconds 50
        }
    }
}

function Read-SessionTurn {
    <#
        The closed-turn count that the Stop hook keeps in the session clock.
        Read, never written: no calibration hook rewrites the clock. Returns
        $null when the clock is missing or unreadable. A pattern reads the one
        number, for the reason Read-CalibrationState gives; a JSON string
        escapes every quote it holds, so the workspace path cannot fake it.
    #>

    [CmdletBinding()]
    [OutputType([Nullable[int]])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ClockPath
    )

    try {
        $match = [regex]::Match([IO.File]::ReadAllText($ClockPath), '"turns"\s*:\s*(\d+)\s*[,}]')

        if (-not $match.Success) {
            return $null
        }

        return [Math]::Max(0, [int]::Parse($match.Groups[1].Value, [Globalization.CultureInfo]::InvariantCulture))
    } catch {
        return $null
    }
}

function Test-BackstopDue {
    <#
        The backstop's two signals, ruling A12: a turn has closed since the
        last injection, or more than 5 minutes have passed since it. Only a
        lastInjectionUtc that SessionStart seeded arms it, and it stops once
        the session has received 12,000 characters of calibration text. The
        5-minute signal waits for the first turn boundary, so a long first turn
        cannot repeat the SessionStart sentence; without a readable session
        clock it is the only signal.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$State,

        [Parameter()]
        [Nullable[int]]$Turns,

        [Parameter(Mandatory = $true)]
        [datetime]$NowUtc
    )

    if ($null -eq $State.LastInjectionUtc -or $State.Characters -ge 12000) {
        return $false
    }

    if ($null -ne $Turns -and $null -ne $State.LastTurn -and $Turns -gt $State.LastTurn) {
        return $true
    }

    return (($null -eq $Turns -or $Turns -ge 1) -and ($NowUtc - $State.LastInjectionUtc).TotalSeconds -gt 300)
}

try {
    if ([string]::IsNullOrEmpty($InputJson)) {
        # Decode explicitly: Windows PowerShell would otherwise use the console
        # input encoding, which mangles non-ASCII payloads that pwsh reads as UTF-8.
        $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false))
        try {
            $InputJson = $reader.ReadToEnd()
        } finally {
            $reader.Dispose()
        }
    }

    if ([string]::IsNullOrWhiteSpace($InputJson)) {
        exit 0
    }

    # Both hosts write session_id ahead of every tool field, so the head of the
    # payload holds it and a tool result of any size stays unparsed here.
    $head = if ($InputJson.Length -gt 4096) { $InputJson.Substring(0, 4096) } else { $InputJson }
    $sessionMatch = [regex]::Match($head, '"session_id"\s*:\s*"([^"\\]*)"')
    $payload = $null
    $sessionId = $null

    if ($sessionMatch.Success) {
        $sessionId = $sessionMatch.Groups[1].Value
    } else {
        # No plain session id: the key falls back to the workspace, so parse.
        $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop
        $sessionId = [string]$payload.session_id
    }

    $workingDirectoryForKey = if ($payload) { [string]$payload.cwd } else { $null }
    if ([string]::IsNullOrWhiteSpace(($sessionId -creplace '[^A-Za-z0-9._-]', '')) -and $null -eq $payload) {
        $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop
        $workingDirectoryForKey = [string]$payload.cwd
    }

    $clockPath = Get-SessionClockPath -SessionId $sessionId -WorkingDirectory $workingDirectoryForKey -Root $ClockRoot
    $statePath = [IO.Path]::ChangeExtension($clockPath, '.familiarity.json')

    if (-not [IO.File]::Exists($statePath)) {
        exit 0
    }

    $state = Read-CalibrationState -StatePath $statePath
    $nowUtc = [DateTime]::UtcNow
    $turns = $null
    $kind = $null

    # At 60,000 characters the session is outside the design's envelope, which
    # its characters field records: nothing of either kind is sent any more.
    if ($state.Characters -lt 60000) {
        if ($state.Compactions -gt $state.Injected) {
            $kind = 'compaction'
        } elseif ($null -ne $state.LastInjectionUtc -and $state.Characters -lt 12000) {
            $turns = Read-SessionTurn -ClockPath $clockPath

            if (Test-BackstopDue -State $state -Turns $turns -NowUtc $nowUtc) {
                $kind = 'backstop'
            }
        }
    }

    if (-not $kind) {
        exit 0
    }

    if ($kind -eq 'compaction') {
        $turns = Read-SessionTurn -ClockPath $clockPath
    }

    if ($null -eq $payload) {
        $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop
    }

    # Never the spawn directory: no cwd means no calibration. Read before the
    # lock: the reader may take seconds, and PreCompact waits for the lock.
    $workspace = [string]$payload.cwd
    $calibration = $null

    if (-not [string]::IsNullOrWhiteSpace($workspace)) {
        $readerPath = @(
            [IO.Path]::Combine($PSScriptRoot, '..', '..', 'skills', 'contributor-profile', 'scripts', 'ContributorProfileReader.ps1')
            [IO.Path]::Combine($PSScriptRoot, '..', '..', '..', 'skills', 'contributor-profile', 'scripts', 'ContributorProfileReader.ps1')
        ) | Where-Object -FilterScript { [IO.File]::Exists($_) } | Select-Object -First 1

        if ($readerPath) {
            . $readerPath
            $calibration = Get-ContributorCalibration -WorkspacePath $workspace -SkipGitForSingleEntry
        }
    }

    $lock = Enter-CalibrationStateLock -StatePath $statePath

    if ($null -eq $lock) {
        # Nothing recorded, so nothing is sent: the next call decides again.
        exit 0
    }

    $sentence = ''
    $recorded = $false

    try {
        # Decide again on the state as it is now: a parallel call may have
        # answered, and PreCompact may have counted another compaction.
        $current = Read-CalibrationState -StatePath $statePath
        $due = $false

        if ($kind -eq 'compaction') {
            $due = $current.Characters -lt 60000 -and $state.Compactions -gt $current.Injected
        } else {
            $due = Test-BackstopDue -State $current -Turns $turns -NowUtc $nowUtc
        }

        if ($due) {
            if ($null -ne $calibration) {
                $sentence = if ($kind -eq 'compaction') {
                    Format-ContributorCalibrationSentence -Calibration $calibration -ReSent
                } else {
                    Format-ContributorCalibrationSentence -Calibration $calibration -Backstop
                }
            }

            # Recorded even with nothing to send, so later calls stay on the
            # short path. Only the compactions read before the calibration step
            # count as answered, so a newer one still gets its own re-send.
            if ($kind -eq 'compaction') {
                $current.Injected = [Math]::Max($current.Injected, $state.Compactions)
            }

            if ($null -ne $current.LastInjectionUtc) {
                $current.LastInjectionUtc = $nowUtc

                if ($null -ne $turns) {
                    $current.LastTurn = $turns
                }
            }

            $current.Characters = $current.Characters + $sentence.Length
            Save-CalibrationState -StatePath $statePath -State $current
            $recorded = $true
        }
    } finally {
        $lock.Dispose()
    }

    if ($recorded -and $sentence) {
        # The levels carry the contributor's area names, which may be letters of any script.
        $json = [ordered]@{
            additionalContext = $sentence
            hookSpecificOutput = [ordered]@{
                hookEventName = 'PostToolUse'
                additionalContext = $sentence
            }
        } | ConvertTo-Json -Depth 5 -Compress
        ConvertTo-AsciiJson -Json $json
    }
} catch {
    # Any fault costs the re-sent levels, nothing else; a tool call is never blocked.
    Write-Debug -Message "Familiarity context not re-sent: $($_.Exception.Message)"
}

exit 0