com.github.copilot/hooks/scripts/Add-SessionContext.ps1
|
<#
.SYNOPSIS SessionStart hook that injects the deterministic part of Pre-flight. .DESCRIPTION Reads the VS Code SessionStart hook payload from standard input, resolves the session working directory, and returns a short block of additional context on standard output: the current UTC timestamp and an authoritative statement of whether a Memory Bank exists in the workspace. The workspace summary supplied at session start omits dotfile folders, which is the recurring cause of agents concluding that no Memory Bank exists. This hook probes the filesystem instead of relying on the model to remember to. It also starts the session clock: the same timestamp is written to a small per-session file under LocalApplicationData, which the Stop hook reads to report the elapsed chat duration at the end of every turn. A model cannot read a clock, so neither number may be left to it. A payload with source 'resume' keeps a readable clock of the same session and reports its start. The command it hands the agent for reading that duration names the clock it wrote or resumed with -Path, so a parallel chat in the same workspace cannot take over the measurement. It names none when no clock is on disk. COPILOT_ATELIER_SESSION_CONTEXT_MAX_CHARS bounds injected context to 1024-16384 characters (default 4096). Invalid values use the default. Long paths are omitted before lifecycle or safety guidance is shortened, and the closing line keeps the clock path longer than the reader path. .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 clock files. Defaults to the per-user application data location. Tests override it to stay off the real profile. .NOTES Writes one JSON object that serves two host contracts with the same text: a top-level additionalContext, the only key the Copilot SDK host and Copilot CLI read, and hookSpecificOutput.additionalContext, the key the VS Code Local harness reads. The JSON is pure ASCII, every other character written as \uXXXX, because Windows PowerShell 5.1 would otherwise encode it in the console's OEM code page, whose best fit turns some characters into a quote. Always exits 0 so a probe failure never blocks a session. #> [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 Write-SessionClose.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. Both sides must derive the same name from the same payload, so change them together. Get-SessionElapsed.ps1 applies the same key rules to COPILOT_AGENT_SESSION_ID, so it changes with them. #> [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-SingleQuotedLiteral { <# Quotes a value as a PowerShell single-quoted string, for a command the agent pastes into its terminal. PowerShell ends such a string at a typographic single quote just as it does at an apostrophe, so each of them is doubled with itself, which the tokenizer reads back as one. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) return "'" + ($Value -replace '([\u0027\u2018\u2019\u201A\u201B])', '$1$1') + "'" } 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 }) } 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() } } $payload = $null if (-not [string]::IsNullOrWhiteSpace($InputJson)) { try { $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop } catch { $payload = $null } } $workingDirectory = if ($payload) { [string]$payload.cwd } else { $null } if ([string]::IsNullOrWhiteSpace($workingDirectory)) { $workingDirectory = (Get-Location).Path } # The path is interpolated into the instruction channel, so strip control # characters that a hostile workspace name could use to inject extra lines. $workingDirectory = $workingDirectory -replace '[\p{Cc}]', ' ' <# The payload supplies this path, so it is untrusted and may name a drive or a shape this host cannot resolve. Combine and probe it through .NET rather than the PowerShell provider: a provider that cannot resolve the path writes to standard error, and the caller merges the streams, which would corrupt the JSON contract on standard output. [System.IO.File]::Exists returns false for any path it cannot read and never throws. #> $memoryBankExists = $false $memoryBankIndex = $workingDirectory try { $memoryBankIndex = [System.IO.Path]::Combine($workingDirectory, '.memory-bank', 'index.md') $memoryBankExists = [System.IO.File]::Exists($memoryBankIndex) } catch { $memoryBankExists = $false } if ($memoryBankExists) { $memoryBankState = "A Memory Bank exists at $memoryBankIndex. This probe is authoritative: " + 'do not conclude that the Memory Bank is absent. Read the index and apply its routing table ' + 'before the first tool call.' } else { $memoryBankState = "No Memory Bank exists under $workingDirectory. This probe is authoritative. " + 'Create one only before a durable repository write, using the memory-bank Skill.' } $startedUtc = (Get-Date).ToUniversalTime() <# Start the session clock. The Stop hook reads this file at the end of every turn to report the elapsed chat duration, and it has to survive compaction, which is why it goes to disk rather than into the injected context alone. A clock failure must never cost the caller its Memory Bank probe. A resumed session keeps its clock. The Copilot SDK runtime reruns this hook with source 'resume' when it reloads a chat, and restarting the clock there reset the elapsed duration and the turn count the Stop hook keeps. A clock that cannot be read, or that names no start, is replaced as before. $pinnedClockPath stays empty unless a clock was written or resumed, so the context never names a clock that is not on disk. #> $pinnedClockPath = $null try { $clockPath = Get-SessionClockPath ` -SessionId ([string]$payload.session_id) ` -WorkingDirectory $workingDirectory ` -Root $ClockRoot $resumedStartUtc = $null if ($payload -and [string]$payload.source -eq 'resume' -and [IO.File]::Exists($clockPath)) { try { $recorded = [IO.File]::ReadAllText($clockPath) | ConvertFrom-Json -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace([string]$recorded.startedUtc)) { $recordedStartUtc = ([datetimeoffset]$recorded.startedUtc).UtcDateTime if ($recordedStartUtc -le $startedUtc) { $resumedStartUtc = $recordedStartUtc } } } catch { $resumedStartUtc = $null } } if ($null -ne $resumedStartUtc) { $startedUtc = $resumedStartUtc $pinnedClockPath = $clockPath } else { $clockDirectory = [IO.Path]::GetDirectoryName($clockPath) if (-not [IO.Directory]::Exists($clockDirectory)) { [IO.Directory]::CreateDirectory($clockDirectory) | Out-Null } $clock = [ordered]@{ startedUtc = $startedUtc.ToString('o') workspace = $workingDirectory turns = 0 } | ConvertTo-Json -Depth 3 [IO.File]::WriteAllText($clockPath, $clock, [Text.UTF8Encoding]::new($false)) $pinnedClockPath = $clockPath } } catch { # A missing clock costs the closing duration line, nothing else. Reporting on # any other stream would corrupt the JSON contract on standard output. Write-Debug -Message "Session clock not started: $($_.Exception.Message)" } <# Hand the agent the absolute path of the clock reader. Post-flight closes with a measured duration, and the agent cannot resolve the reader itself: it sits under ~/.copilot when deployed and under the plugin root when installed as a plugin, which is the probe hooks.json already carries. Also name the clock this hook just wrote or resumed. Given no clock, the reader falls back to the newest one of the workspace, and a parallel chat in the same workspace may have written a newer clock than this session's own. #> $elapsedReader = [IO.Path]::Combine($PSScriptRoot, 'Get-SessionElapsed.ps1') $readerCommand = '& ' + (ConvertTo-SingleQuotedLiteral -Value $elapsedReader) $shortClosingLine = 'Close every reply with the measured Get-SessionElapsed.ps1 output in POST-FLIGHT. The reader is beside the SessionStart hook.' $pinnedClosingLine = $null if ($pinnedClockPath) { $quotedClockPath = ConvertTo-SingleQuotedLiteral -Value $pinnedClockPath $readerCommand = "$readerCommand -Path $quotedClockPath" $pinnedClosingLine = "Close every reply with the measured Get-SessionElapsed.ps1 output in POST-FLIGHT, run with -Path $quotedClockPath. The reader is beside the SessionStart hook." } $line = @( "Session started at $($startedUtc.ToString('yyyy-MM-dd HH:mm')) UTC." $memoryBankState 'Open the reply with that UTC timestamp and a one-line PRE-FLIGHT acknowledgment.' "Close every reply by running $readerCommand and copying its single line verbatim as the last line of the POST-FLIGHT block." 'Never push or otherwise mutate a git remote unless the user asks in the current turn.' ) $contextLimit = 4096 $configuredLimit = 0 if ([int]::TryParse([Environment]::GetEnvironmentVariable('COPILOT_ATELIER_SESSION_CONTEXT_MAX_CHARS'), [ref]$configuredLimit) -and $configuredLimit -ge 1024 -and $configuredLimit -le 16384) { $contextLimit = $configuredLimit } $additionalContext = $line -join ' ' if ($additionalContext.Length -gt $contextLimit) { $line[1] = if ($memoryBankExists) { 'A Memory Bank exists in the working directory. Read its index and apply its routes before the first tool call.' } else { 'The Memory Bank probe found no index in the working directory. Check before initializing; create only missing files for durable work.' } $additionalContext = $line -join ' ' } <# The shortened closing line exists for a reader path too long to send, and that is not the clock path. The first shortening therefore keeps -Path; the second drops it as well when even that does not fit. #> if ($additionalContext.Length -gt $contextLimit) { $line[3] = if ($pinnedClosingLine) { $pinnedClosingLine } else { $shortClosingLine } $additionalContext = $line -join ' ' } if ($additionalContext.Length -gt $contextLimit) { $line[3] = $shortClosingLine $additionalContext = $line -join ' ' } $output = [ordered]@{ continue = $true # Two hosts, two contracts. The Copilot SDK host and Copilot CLI read only a # top-level additionalContext; the VS Code Local harness reads only # hookSpecificOutput. Each host ignores the other's key, so carry both. additionalContext = $additionalContext hookSpecificOutput = [ordered]@{ hookEventName = 'SessionStart' additionalContext = $additionalContext } } $json = $output | ConvertTo-Json -Depth 5 -Compress ConvertTo-AsciiJson -Json $json exit 0 |