PwshCopilot.psm1
# Load internals . "$PSScriptRoot\Private\Config.ps1" . "$PSScriptRoot\Private\LLMClient.ps1" . "$PSScriptRoot\Private\Completer.ps1" . "$PSScriptRoot\Private\WhisperClient.ps1" # Explicitly export voice init if loaded (helps when reloading in existing session) if (Get-Command Initialize-PwshCopilotVoice -ErrorAction SilentlyContinue) { Export-ModuleMember -Function Initialize-PwshCopilotVoice -Alias Initialize-VoiceCopilot -ErrorAction SilentlyContinue } if (Get-Command Test-PSCopilotPrerequisites -ErrorAction SilentlyContinue) { Export-ModuleMember -Function Test-PSCopilotPrerequisites -ErrorAction SilentlyContinue } if (Get-Command Invoke-WhisperTranscription -ErrorAction SilentlyContinue) { Export-ModuleMember -Function Invoke-WhisperTranscription -ErrorAction SilentlyContinue } # Ensure configuration on import (prompts on first run if missing) Initialize-PwshCopilot function Get-PSCommandSuggestion { param([string]$Description) $prompt = "Convert to PowerShell:\n$Description" Invoke-PSCopilotLLM -Prompt $prompt } Export-ModuleMember -Function Get-PSCommandSuggestion -ErrorAction SilentlyContinue # Helper: extract PowerShell code from LLM responses function Convert-LLMResponseToPSCommand { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $codeBlockMatch = [regex]::Match($Text, '(?s)```(?:[a-zA-Z]+)?\s*(.*?)```') if ($codeBlockMatch.Success) { return ($codeBlockMatch.Groups[1].Value.Trim()) } $lines = $Text -split "(`r`n|`n|`r)" $candidateLines = $lines | Where-Object { $_ -and ($_ -match '(\||\b(Get|Set|New|Remove|Start|Stop|Restart|Invoke|Enable|Disable|Select|Sort|Where|ForEach|Import|Export|Test|Measure|Write|Add|Clear|Copy|Move|Rename|Join|Split|Out|Format)-\w+)') } if ($candidateLines.Count -gt 0) { return (($candidateLines -join "`n").Trim()) } return $Text.Trim() } # Helper: detect exit intent from natural language function Test-PSCopilotExitIntent { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } $patterns = @( '^\s*(exit|quit|quite|q)\b', '^\s*(bye|goodbye)\b', '^\s*(end|stop|close|terminate)\b', 'close\s+(the\s+)?(session|chat|pscopilotsession)\b', 'end\s+(the\s+)?(session|chat)\b', '(session|chat)\s+(close|end)\b', '\bthank\s*(you)?\b' ) foreach ($p in $patterns) { if ($Text -match $p) { return $true } } return $false } # Helper: detect assistant invitation to continue function Test-PSCopilotInviteToContinue { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } $patterns = @( 'more\s+questions', 'anything\s+else', 'feel\s+free\s+to\s+ask', 'let\s+me\s+know\s+if', 'any\s+other\s+question', 'what\s+else\s+can\s+i\s+help', 'need\s+anything\s+else', 'do\s+you\s+have\s+any\s+other' ) foreach ($p in $patterns) { if ($Text -match $p) { return $true } } return $false } # Helper: detect a simple negative response like "no" function Test-PSCopilotNegativeResponse { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } $patterns = @( '^\s*(no|nope|nah)\b', '^\s*(not\s+now|nothing|all\s+good)\b', '^(that''s|that\s+is)\s+all\b', '^\s*(i\s*(am|''m)\s+good)\b', '^\s*(no\s+thanks?|no\s+thank\s+you)\b' ) foreach ($p in $patterns) { if ($Text -match $p) { return $true } } return $false } function Get-PSCommandExplanation { param([string]$Command) $prompt = "Explain in plain English what this does:\n$Command" Invoke-PSCopilotLLM -Prompt $prompt } Set-Alias -Name Explain-PSCommand -Value Get-PSCommandExplanation Export-ModuleMember -Function Get-PSCommandExplanation -Alias Explain-PSCommand -ErrorAction SilentlyContinue function New-PSHelperScript { param([string]$Description, [string]$OutputPath = "$env:USERPROFILE\Documents\PwshCopilot") if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath | Out-Null } $prompt = "Write a PowerShell script to do:\n$Description" $script = Invoke-PSCopilotLLM -Prompt $prompt $fileName = "Script_{0}.ps1" -f (Get-Date -Format "yyyyMMdd_HHmmss") $filePath = Join-Path $OutputPath $fileName $script | Set-Content $filePath Write-Host "Script saved: $filePath" } Export-ModuleMember -Function New-PSHelperScript -ErrorAction SilentlyContinue function Start-PSCopilotSession { Write-Host "PwshCopilot session started. Type 'exit' to quit." $messages = @() $systemPrompt = "You are a helpful PowerShell assistant. Answer concisely. When asked to generate commands, return only PowerShell unless explanation is explicitly requested. Prefer single-line commands for direct execution." $awaitingMore = $false while ($true) { $inputText = Read-Host "You" if ($inputText -eq "exit") { break } if ([string]::IsNullOrWhiteSpace($inputText)) { continue } if (Test-PSCopilotExitIntent -Text $inputText) { Write-Host "Ending session. Goodbye!" -ForegroundColor Cyan break } if ($awaitingMore -and (Test-PSCopilotNegativeResponse -Text $inputText)) { Write-Host "Okay, closing the session. Goodbye!" -ForegroundColor Cyan break } $messages += @{ role = 'user'; content = $inputText } $response = Invoke-PSCopilotLLMChat -Messages $messages -SystemPrompt $systemPrompt if (-not $response) { Write-Host "No response" -ForegroundColor Yellow; continue } $messages += @{ role = 'assistant'; content = $response } $awaitingMore = Test-PSCopilotInviteToContinue -Text $response $commandToRun = Convert-LLMResponseToPSCommand -Text $response Write-Host "Copilot:" -ForegroundColor Green Write-Host $response Write-Host "\nCommand candidate:" -ForegroundColor Green Write-Host $commandToRun -ForegroundColor Cyan $confirm = Read-Host "Run this command? (y/n)" if ($confirm -match '^(y|yes)$') { try { Write-Host "Executing..." -ForegroundColor Yellow Invoke-Expression -Command $commandToRun | Out-Host } catch { Write-Error $_ } } else { Write-Host "Skipped." } } } Export-ModuleMember -Function Start-PSCopilotSession -ErrorAction SilentlyContinue function Invoke-PSCopilotDemo { param( [Parameter(Mandatory=$true)] [string[]]$Prompts, [string]$ExitInput = 'thanks', [switch]$SimulateNoAfterInvite ) $systemPrompt = 'You are a helpful PowerShell assistant. Answer concisely. When asked to generate commands, return only PowerShell unless explanation is explicitly requested. Prefer single-line commands for direct execution.' $messages = @() $awaitingMore = $false foreach ($p in $Prompts) { $messages += @{ role = 'user'; content = $p } $response = Invoke-PSCopilotLLMChat -Messages $messages -SystemPrompt $systemPrompt Write-Host "Assistant:" -ForegroundColor Green Write-Host $response $cmd = Convert-LLMResponseToPSCommand -Text $response Write-Host "Command:" -ForegroundColor Green Write-Host $cmd -ForegroundColor Cyan if ($cmd) { Write-Host "Executing..." -ForegroundColor Yellow Invoke-Expression -Command $cmd | Out-Host } $messages += @{ role = 'assistant'; content = $response } $awaitingMore = Test-PSCopilotInviteToContinue -Text $response if ($awaitingMore -and $SimulateNoAfterInvite) { Write-Host "User: no" -ForegroundColor Magenta if (Test-PSCopilotNegativeResponse -Text 'no') { Write-Host 'Okay, closing the session. Goodbye!' -ForegroundColor Cyan return } } } if (Test-PSCopilotExitIntent -Text $ExitInput) { Write-Host 'Ending session. Goodbye!' -ForegroundColor Cyan } } Export-ModuleMember -Function Invoke-PSCopilotDemo -ErrorAction SilentlyContinue function Enable-PSCopilotCompletion { # Helper: Collect session context (recent commands + last error) function Get-PSCopilotContext { $history = (Get-History | Select-Object -Last 10 | ForEach-Object { $_.CommandLine }) -join "`n" $lastError = if ($Error.Count -gt 0) { $Error[0].ToString() } else { "" } return @{ History = $history LastError = $lastError } } # Override the completer to send context to LLM function Register-PSCopilotCompleter { Register-ArgumentCompleter -CommandName * -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParams) $context = Get-PSCopilotContext $prompt = @" The user is typing a PowerShell command. Recent history: $($context.History) Last error: $($context.LastError) Partial input: $wordToComplete Suggest several possible next completions. Return them as a list. "@ # Call LLM using module helper $suggestions = Invoke-PSCopilotLLM -Prompt $prompt # Parse into multiple suggestions (split by newline or semicolon) $choices = $suggestions -split "[`n;]" | Where-Object { $_ -match '\\S' } foreach ($c in $choices) { [System.Management.Automation.CompletionResult]::new( $c.Trim(), $c.Trim(), 'ParameterValue', $c.Trim() ) } } } # Register the completer with AI backend Register-PSCopilotCompleter Write-Host "PwshCopilot live AI completion enabled! Use Tab to cycle through multiple AI suggestions." } Export-ModuleMember -Function Enable-PSCopilotCompletion -ErrorAction SilentlyContinue # Voice-driven session: capture spoken requests -> generate command -> confirm -> optional execute function Start-PSCopilotVoiceSession { [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')] param( [int] $CaptureSeconds = 5, [switch] $AutoExecuteOnSingleSuggestion, [switch] $NoAudioOutput, [switch] $VerboseTranscripts, [string] $DeviceName ) # Ensure configs if (-not (Test-PSCopilotPrerequisites -Silent)) { Write-Host "Run 'Test-PSCopilotPrerequisites' after installing missing components, then retry." -ForegroundColor Yellow } $voiceCfg = $null try { $voiceCfg = Get-PSCopilotVoiceConfig } catch { Write-Warning "Voice config incomplete; continuing with local fallback if available." } # Choose transcription + (optional) output strategy based on provider $useWhisper = $voiceCfg -and $voiceCfg.SpeechProvider -eq 'AzureOpenAIWhisper' if ($useWhisper) { $transcribe = { param($secs,$dev) Invoke-WhisperTranscription -UseMicrophone -Seconds $secs -DeviceName $dev } $speak = { param($text) Write-Host $text -ForegroundColor Gray } # No TTS path for Whisper (yet) } else { $transcribe = { param($secs,$dev) Invoke-PSCopilotVoiceInput -UseMicrophone -Seconds $secs } $speak = { param($text) if (-not $NoAudioOutput) { $text | Invoke-PSCopilotVoiceOutput } else { Write-Host $text -ForegroundColor Gray } } } $systemPrompt = "Convert user's natural language request into a valid PowerShell command. Do not execute. Return only the command." # As requested Write-Host "Voice session started. When prompted, speak your request then stay silent. Say 'thank you' or 'exit' to finish." -ForegroundColor Cyan $exit = $false while (-not $exit) { Write-Host "Listening ($CaptureSeconds s)..." -ForegroundColor DarkGray $userText = & $transcribe $CaptureSeconds $DeviceName if (-not $userText -and ($voiceCfg.SpeechProvider -eq 'AzureOpenAIWhisper')) { if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Warning "ffmpeg not found. Install it (e.g. winget install Gyan.FFmpeg) OR record a WAV and pass -AudioPath via Invoke-WhisperTranscription."; break } Write-Host "(No audio captured. If this persists, list devices with: Invoke-WhisperTranscription -UseMicrophone -ListDevices)" -ForegroundColor Yellow } if ($VerboseTranscripts) { Write-Host "[You]: $userText" -ForegroundColor Magenta } if (-not $userText) { Write-Host "(No speech detected)" -ForegroundColor Yellow; continue } if (Test-PSCopilotExitIntent -Text $userText) { break } # Build single-turn messages for clarity $messages = @(@{ role = 'user'; content = $userText }) Write-Host "Processing (transcription -> command)..." -ForegroundColor DarkGray $command = Invoke-PSCopilotLLMChat -Messages $messages -SystemPrompt $systemPrompt if ([string]::IsNullOrWhiteSpace($command)) { $speak = "I couldn't derive a command. Please try again."; if (-not $NoAudioOutput) { $speak | Invoke-PSCopilotVoiceOutput } else { Write-Host $speak -ForegroundColor Yellow } continue } # Strip any accidental formatting (code fences) $command = ($command -replace '(?s)```(?:powershell|ps1)?','' -replace '```','').Trim() Write-Host "Suggested command:" -ForegroundColor Green Write-Host $command -ForegroundColor Cyan if ($AutoExecuteOnSingleSuggestion) { Write-Host "(AutoExecute enabled)" -ForegroundColor Yellow try { Invoke-Expression -Command $command } catch { Write-Error $_ } continue } $confirmPrompt = "Confirm (voice: say yes/no OR type y/n). Press Enter to skip confirmation and speak a new request."; Write-Host $confirmPrompt -ForegroundColor Gray # Offer immediate typed answer $typedFirst = Read-Host "Type y to run, n to skip, or just Enter to use voice" $confirmSpeech = $null if (-not [string]::IsNullOrWhiteSpace($typedFirst)) { $norm = $typedFirst.ToLowerInvariant() } else { Write-Host "Listening (confirmation 3s)..." -ForegroundColor DarkGray $confirmSpeech = & $transcribe 3 $DeviceName if ($VerboseTranscripts) { Write-Host "[Confirm]: $confirmSpeech" -ForegroundColor DarkCyan } if (-not $confirmSpeech) { continue } if (Test-PSCopilotExitIntent -Text $confirmSpeech) { $exit = $true; break } $norm = $confirmSpeech.ToLowerInvariant() } $yesTokens = 'y','yes','yeah','sure','run','execute','do it' $noTokens = 'n','no','nope','skip','cancel' if ($yesTokens -contains $norm) { try { if ($PSCmdlet.ShouldProcess($command,'Invoke generated command')) { Write-Host "Executing..." -ForegroundColor Yellow Invoke-Expression -Command $command | Out-Host Write-Host "Done." -ForegroundColor DarkGray } else { Write-Host "WhatIf: Skipped execution." -ForegroundColor DarkYellow } } catch { Write-Error $_ } } elseif ($noTokens -contains $norm) { Write-Host "Skipped." -ForegroundColor DarkYellow continue } else { # Treat as next request text: loop continues using this as new userText (tail recursion style) if ($VerboseTranscripts) { Write-Host "Treating confirmation speech as new request." -ForegroundColor DarkYellow } $userText = if ($confirmSpeech) { $confirmSpeech } else { $typedFirst } if (Test-PSCopilotExitIntent -Text $userText) { $exit = $true; break } $messages = @(@{ role = 'user'; content = $userText }) $command = Invoke-PSCopilotLLMChat -Messages $messages -SystemPrompt $systemPrompt if ($command) { $command = ($command -replace '(?s)```(?:powershell|ps1)?','' -replace '```','').Trim() Write-Host "Suggested command:" -ForegroundColor Green Write-Host $command -ForegroundColor Cyan } } } $goodbye = "Ending voice session. Goodbye."; Write-Host $goodbye -ForegroundColor Cyan } # Basic voice session (no TTS): voice -> text -> command -> voice confirm -> execute function Start-PSCopilotVoiceSessionBasic { [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')] param( [int] $CaptureSeconds = 5, [switch] $VerboseTranscripts, [string] $DeviceName ) Write-Host "Basic voice session (no audio output). Say a request. Say 'thank you' or 'exit' to end." -ForegroundColor Cyan $systemPrompt = "Convert user's natural language request into a valid PowerShell command. Do not execute. Return only the command." if (-not (Test-PSCopilotPrerequisites -Silent)) { Write-Host "Run 'Test-PSCopilotPrerequisites' after installing missing components, then retry." -ForegroundColor Yellow } $voiceCfg = $null try { $voiceCfg = Get-PSCopilotVoiceConfig } catch {} $useWhisper = $voiceCfg -and $voiceCfg.SpeechProvider -eq 'AzureOpenAIWhisper' if ($useWhisper) { $transcribe = { param($secs,$dev) Invoke-WhisperTranscription -UseMicrophone -Seconds $secs -DeviceName $dev } } else { $transcribe = { param($secs,$dev) Invoke-PSCopilotVoiceInput -UseMicrophone -Seconds $secs } } while ($true) { Write-Host "Listening ($CaptureSeconds s)..." -ForegroundColor DarkGray $request = & $transcribe $CaptureSeconds $DeviceName if ($VerboseTranscripts) { Write-Host "[You]: $request" -ForegroundColor Magenta } if (-not $request) { Write-Host "(No speech)" -ForegroundColor Yellow; continue } if (Test-PSCopilotExitIntent -Text $request) { break } $cmd = Invoke-PSCopilotLLMChat -Messages @(@{ role='user'; content=$request }) -SystemPrompt $systemPrompt if (-not $cmd) { Write-Host "(No command generated)" -ForegroundColor Yellow; continue } $cmd = ($cmd -replace '(?s)```(?:powershell|ps1)?','' -replace '```','').Trim() Write-Host "Suggested:" -ForegroundColor Green Write-Host $cmd -ForegroundColor Cyan Write-Host "Say yes to run, no to skip, or an exit phrase to stop." -ForegroundColor Gray Write-Host "Listening (confirmation 3s)..." -ForegroundColor DarkGray Write-Host "Confirm (voice yes/no OR type y/n)." -ForegroundColor Gray $typedFirst = Read-Host "Type y to run, n to skip, or Enter to use voice" $confirm = $null if ([string]::IsNullOrWhiteSpace($typedFirst)) { Write-Host "Listening (confirmation 3s)..." -ForegroundColor DarkGray $confirm = & $transcribe 3 $DeviceName } else { $confirm = $typedFirst } if ($VerboseTranscripts) { Write-Host "[Confirm]: $confirm" -ForegroundColor DarkCyan } if (-not $confirm) { continue } if (Test-PSCopilotExitIntent -Text $confirm) { break } $cNorm = $confirm.ToLowerInvariant() if ($cNorm -in @('y','yes','yeah','run','execute','sure')) { try { if ($PSCmdlet.ShouldProcess($cmd,'Invoke generated command')) { Write-Host "Executing..." -ForegroundColor Yellow; Invoke-Expression -Command $cmd | Out-Host } else { Write-Host 'WhatIf: Skipped execution.' -ForegroundColor DarkYellow } } catch { Write-Error $_ } } elseif ($cNorm -in @('n','no','nope','skip','cancel')) { Write-Host "Skipped." -ForegroundColor DarkYellow } else { # treat as new request on next loop iteration by reassigning if (Test-PSCopilotExitIntent -Text $cNorm) { break } } } Write-Host "Voice session ended." -ForegroundColor Cyan } function Start-VoiceCopilot { [CmdletBinding(SupportsShouldProcess=$true)] param( [int] $CaptureSeconds = 5, [switch] $VerboseTranscripts, [switch] $Basic, [string] $DeviceName ) if ($Basic) { Start-PSCopilotVoiceSessionBasic -CaptureSeconds $CaptureSeconds -VerboseTranscripts:$VerboseTranscripts -DeviceName $DeviceName } else { Start-PSCopilotVoiceSession -CaptureSeconds $CaptureSeconds -VerboseTranscripts:$VerboseTranscripts -DeviceName $DeviceName } } Export-ModuleMember -Function Start-VoiceCopilot |