@ludi-uni/ludi-agent-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. package/tests/turn-budget.test.mjs +106 -0
@@ -0,0 +1,102 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory)]
4
+ [ValidateNotNullOrEmpty()]
5
+ [string]$AudioSource,
6
+ [double]$Duration = 5,
7
+ [int]$Fps = 20,
8
+ [ValidateSet('desktop', 'region', 'window')]
9
+ [string]$VideoMode = 'desktop',
10
+ [Nullable[int]]$X,
11
+ [Nullable[int]]$Y,
12
+ [Nullable[int]]$Width,
13
+ [Nullable[int]]$Height,
14
+ [string]$WindowTitle,
15
+ [IntPtr]$Hwnd = [IntPtr]::Zero,
16
+ [string]$OutputPath,
17
+ [string]$RunDirectory,
18
+ [switch]$Overwrite
19
+ )
20
+
21
+ Set-StrictMode -Version Latest
22
+
23
+ function Get-DirectShowAudioDeviceNames {
24
+ [OutputType([string[]])]
25
+ param([Parameter(Mandatory)][string]$FfmpegPath)
26
+
27
+ $output = & $FfmpegPath '-hide_banner' '-list_devices' 'true' '-f' 'dshow' '-i' 'dummy' 2>&1
28
+ $audioSources = [System.Collections.Generic.List[string]]::new()
29
+ foreach ($line in $output) {
30
+ $match = [regex]::Match($line.ToString(), '"(?<name>.+)" \(audio\)')
31
+ if ($match.Success) {
32
+ [void]$audioSources.Add($match.Groups['name'].Value)
33
+ }
34
+ }
35
+ return @($audioSources)
36
+ }
37
+
38
+ $common = Join-Path $PSScriptRoot 'common.ps1'
39
+ . $common
40
+
41
+ if ([double]::IsNaN($Duration) -or [double]::IsInfinity($Duration) -or $Duration -lt 1 -or $Duration -gt 60) {
42
+ throw 'Duration must be between 1 and 60 seconds.'
43
+ }
44
+ if ($Fps -lt 1 -or $Fps -gt 60) {
45
+ throw 'Fps must be between 1 and 60.'
46
+ }
47
+
48
+ $ffmpegPath = Get-FfmpegPath
49
+ $availableAudioSources = Get-DirectShowAudioDeviceNames -FfmpegPath $ffmpegPath
50
+ if ($availableAudioSources -notcontains $AudioSource) {
51
+ throw "AudioSource is not an enumerated DirectShow audio device: $AudioSource"
52
+ }
53
+
54
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'av'
55
+ $resolvedRunDirectory = $run.RunDirectory
56
+ if ([string]::IsNullOrWhiteSpace($OutputPath)) {
57
+ $OutputPath = Join-Path $resolvedRunDirectory 'capture.mp4'
58
+ }
59
+ $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
60
+ Assert-OutputAvailable -Path $resolvedOutputPath -Overwrite:$Overwrite
61
+
62
+ $target = Resolve-CaptureTarget -Mode $VideoMode -X $X -Y $Y -Width $Width -Height $Height -WindowTitle $WindowTitle -Hwnd $Hwnd
63
+ $invariantCulture = [Globalization.CultureInfo]::InvariantCulture
64
+ $durationText = $Duration.ToString('G17', $invariantCulture)
65
+ $fpsText = $Fps.ToString($invariantCulture)
66
+ $ffmpegArguments = [string[]]@(
67
+ '-f', 'gdigrab',
68
+ '-draw_mouse', '1',
69
+ '-framerate', $fpsText
70
+ ) + $target.InputOptions + @(
71
+ '-f', 'dshow',
72
+ '-i', "audio=$AudioSource",
73
+ '-t', $durationText,
74
+ '-map', '0:v:0',
75
+ '-map', '1:a:0',
76
+ '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:color=black',
77
+ '-c:v', 'libx264',
78
+ '-preset', 'veryfast',
79
+ '-crf', '23',
80
+ '-pix_fmt', 'yuv420p',
81
+ '-c:a', 'aac',
82
+ '-b:a', '192k',
83
+ '-movflags', '+faststart',
84
+ '-r', $fpsText
85
+ )
86
+ if ($Overwrite) {
87
+ $ffmpegArguments += '-y'
88
+ }
89
+ $ffmpegArguments += $resolvedOutputPath
90
+
91
+ Invoke-Ffmpeg -FfmpegPath $ffmpegPath -Arguments $ffmpegArguments -Operation "same-session A/V recording $($target.TargetDescription)"
92
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
93
+ schema_version = 'agent-verification-lab.result.v1'
94
+ status = 'ok'
95
+ kind = $run.Kind
96
+ run_id = $run.RunId
97
+ run_directory = $resolvedRunDirectory
98
+ artifacts = @([pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'video/mp4' })
99
+ backend = [pscustomobject]@{ name = 'ffmpeg'; version = $null }
100
+ warnings = @()
101
+ })
102
+ Write-VisualResult -RunDirectory $resolvedRunDirectory -OutputPath $resolvedOutputPath -Kind 'av' -ResultJsonPath $resultJson
@@ -0,0 +1,72 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [double]$Duration = 5,
4
+ [int]$Fps = 20,
5
+ [ValidateSet('desktop', 'region', 'window')]
6
+ [string]$Mode = 'desktop',
7
+ [Nullable[int]]$X,
8
+ [Nullable[int]]$Y,
9
+ [Nullable[int]]$Width,
10
+ [Nullable[int]]$Height,
11
+ [string]$WindowTitle,
12
+ [IntPtr]$Hwnd = [IntPtr]::Zero,
13
+ [string]$OutputPath,
14
+ [string]$RunDirectory,
15
+ [switch]$Overwrite
16
+ )
17
+
18
+ $common = Join-Path $PSScriptRoot 'common.ps1'
19
+ . $common
20
+
21
+ if ([double]::IsNaN($Duration) -or [double]::IsInfinity($Duration) -or $Duration -lt 1 -or $Duration -gt 60) {
22
+ throw 'Duration must be between 1 and 60 seconds.'
23
+ }
24
+
25
+ if ($Fps -lt 1 -or $Fps -gt 60) {
26
+ throw 'Fps must be between 1 and 60.'
27
+ }
28
+
29
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
30
+ $resolvedRunDirectory = $run.RunDirectory
31
+ if ([string]::IsNullOrWhiteSpace($OutputPath)) {
32
+ $OutputPath = Join-Path $resolvedRunDirectory 'recording.mp4'
33
+ }
34
+
35
+ $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
36
+ Assert-OutputAvailable -Path $resolvedOutputPath -Overwrite:$Overwrite
37
+ $target = Resolve-CaptureTarget -Mode $Mode -X $X -Y $Y -Width $Width -Height $Height -WindowTitle $WindowTitle -Hwnd $Hwnd
38
+ $invariantCulture = [Globalization.CultureInfo]::InvariantCulture
39
+ $durationText = $Duration.ToString('G17', $invariantCulture)
40
+ $fpsText = $Fps.ToString($invariantCulture)
41
+ $ffmpegArguments = [string[]]@(
42
+ '-f', 'gdigrab',
43
+ '-draw_mouse', '1',
44
+ '-framerate', $fpsText
45
+ ) + $target.InputOptions + @(
46
+ '-t', $durationText,
47
+ '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:color=black',
48
+ '-an',
49
+ '-c:v', 'libx264',
50
+ '-preset', 'veryfast',
51
+ '-crf', '23',
52
+ '-pix_fmt', 'yuv420p',
53
+ '-movflags', '+faststart',
54
+ '-r', $fpsText
55
+ )
56
+ if ($Overwrite) {
57
+ $ffmpegArguments += '-y'
58
+ }
59
+ $ffmpegArguments += $resolvedOutputPath
60
+
61
+ Invoke-Ffmpeg -FfmpegPath (Get-FfmpegPath) -Arguments $ffmpegArguments -Operation "recording $($target.TargetDescription)"
62
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
63
+ schema_version = 'agent-verification-lab.result.v1'
64
+ status = 'ok'
65
+ kind = $run.Kind
66
+ run_id = $run.RunId
67
+ run_directory = $resolvedRunDirectory
68
+ artifacts = @([pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'video/mp4' })
69
+ backend = [pscustomobject]@{ name = 'ffmpeg'; version = $null }
70
+ warnings = @()
71
+ })
72
+ Write-VisualResult -RunDirectory $resolvedRunDirectory -OutputPath $resolvedOutputPath -Kind 'recording' -ResultJsonPath $resultJson
@@ -0,0 +1,44 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [ValidateSet('desktop', 'region', 'window')]
4
+ [string]$Mode = 'desktop',
5
+ [Nullable[int]]$X,
6
+ [Nullable[int]]$Y,
7
+ [Nullable[int]]$Width,
8
+ [Nullable[int]]$Height,
9
+ [string]$WindowTitle,
10
+ [IntPtr]$Hwnd = [IntPtr]::Zero,
11
+ [string]$OutputPath,
12
+ [string]$RunDirectory,
13
+ [switch]$Overwrite
14
+ )
15
+
16
+ $common = Join-Path $PSScriptRoot 'common.ps1'
17
+ . $common
18
+
19
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
20
+ $resolvedRunDirectory = $run.RunDirectory
21
+ if ([string]::IsNullOrWhiteSpace($OutputPath)) {
22
+ $OutputPath = Join-Path $resolvedRunDirectory 'screenshot.png'
23
+ }
24
+ $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
25
+ Assert-OutputAvailable -Path $resolvedOutputPath -Overwrite:$Overwrite
26
+ $target = Resolve-CaptureTarget -Mode $Mode -X $X -Y $Y -Width $Width -Height $Height -WindowTitle $WindowTitle -Hwnd $Hwnd
27
+ $ffmpegArguments = [string[]]@('-f', 'gdigrab', '-framerate', '1', '-draw_mouse', '1') + $target.InputOptions + @('-frames:v', '1', '-c:v', 'png')
28
+ if ($Overwrite) {
29
+ $ffmpegArguments += '-y'
30
+ }
31
+ $ffmpegArguments += $resolvedOutputPath
32
+
33
+ Invoke-Ffmpeg -FfmpegPath (Get-FfmpegPath) -Arguments $ffmpegArguments -Operation "static $($target.TargetDescription) capture"
34
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
35
+ schema_version = 'agent-verification-lab.result.v1'
36
+ status = 'ok'
37
+ kind = $run.Kind
38
+ run_id = $run.RunId
39
+ run_directory = $resolvedRunDirectory
40
+ artifacts = @([pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'image/png' })
41
+ backend = [pscustomobject]@{ name = 'ffmpeg'; version = $null }
42
+ warnings = @()
43
+ })
44
+ Write-VisualResult -RunDirectory $resolvedRunDirectory -OutputPath $resolvedOutputPath -Kind 'screenshot' -ResultJsonPath $resultJson
@@ -0,0 +1,450 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory)]
4
+ [ValidateNotNullOrEmpty()]
5
+ [string]$Input,
6
+ [Parameter(Mandatory)]
7
+ [ValidateNotNullOrEmpty()]
8
+ [string]$OutputPng,
9
+ [Parameter(Mandatory)]
10
+ [ValidateNotNullOrEmpty()]
11
+ [string]$OutputEventsJson,
12
+ [int]$Width = 1280,
13
+ [int]$Height = 240,
14
+ [double]$SilenceDb = -35,
15
+ [double]$MinSilenceSeconds = 0.2
16
+ )
17
+
18
+ Set-StrictMode -Version Latest
19
+
20
+ $common = Join-Path $PSScriptRoot 'common.ps1'
21
+ . $common
22
+ $inputPath = $PSBoundParameters['Input']
23
+
24
+ function Resolve-NewOutputPath {
25
+ [OutputType([string])]
26
+ param(
27
+ [Parameter(Mandatory)]
28
+ [string]$Path,
29
+ [Parameter(Mandatory)]
30
+ [string]$ExpectedExtension,
31
+ [Parameter(Mandatory)]
32
+ [string]$ParameterName
33
+ )
34
+
35
+ $resolvedPath = [IO.Path]::GetFullPath($Path)
36
+ if ([IO.Path]::GetExtension($resolvedPath) -ine $ExpectedExtension) {
37
+ throw "$ParameterName must use the '$ExpectedExtension' extension: $resolvedPath"
38
+ }
39
+
40
+ $directory = Split-Path -Parent $resolvedPath
41
+ if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
42
+ throw "$ParameterName directory does not exist: $directory"
43
+ }
44
+
45
+ if (Test-Path -LiteralPath $resolvedPath) {
46
+ throw "$ParameterName already exists; refusing to overwrite: $resolvedPath"
47
+ }
48
+
49
+ return $resolvedPath
50
+ }
51
+
52
+ function ConvertTo-InvariantDouble {
53
+ [OutputType([double])]
54
+ param([Parameter(Mandatory)][string]$Text)
55
+
56
+ return [double]::Parse($Text, [Globalization.CultureInfo]::InvariantCulture)
57
+ }
58
+
59
+ function Get-WaveformNativeMethods {
60
+ [OutputType([type])]
61
+ param()
62
+
63
+ $nativeMethods = 'AgentVerificationLab.WaveformNativeMethods' -as [type]
64
+ if ($null -eq $nativeMethods) {
65
+ Add-Type -TypeDefinition @'
66
+ using System;
67
+ using System.Runtime.InteropServices;
68
+ using Microsoft.Win32.SafeHandles;
69
+
70
+ namespace AgentVerificationLab
71
+ {
72
+ public static class WaveformNativeMethods
73
+ {
74
+ public const uint GenericWrite = 0x40000000;
75
+ public const uint Delete = 0x00010000;
76
+ public const uint CreateNew = 1;
77
+ public const uint OpenExisting = 3;
78
+ public const uint FileAttributeNormal = 0x00000080;
79
+ public const int FileDispositionInfo = 4;
80
+
81
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
82
+ public struct FILE_DISPOSITION_INFO
83
+ {
84
+ [MarshalAs(UnmanagedType.U1)]
85
+ public bool DeleteFile;
86
+ }
87
+
88
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
89
+ public static extern SafeFileHandle CreateFile(
90
+ string lpFileName,
91
+ uint dwDesiredAccess,
92
+ uint dwShareMode,
93
+ IntPtr lpSecurityAttributes,
94
+ uint dwCreationDisposition,
95
+ uint dwFlagsAndAttributes,
96
+ IntPtr hTemplateFile);
97
+
98
+ [DllImport("kernel32.dll", SetLastError = true)]
99
+ [return: MarshalAs(UnmanagedType.Bool)]
100
+ public static extern bool SetFileInformationByHandle(
101
+ SafeFileHandle hFile,
102
+ int FileInformationClass,
103
+ ref FILE_DISPOSITION_INFO lpFileInformation,
104
+ uint dwBufferSize);
105
+ }
106
+ }
107
+ '@
108
+ $nativeMethods = 'AgentVerificationLab.WaveformNativeMethods' -as [type]
109
+ }
110
+
111
+ return $nativeMethods
112
+ }
113
+
114
+ function Open-NewOutputStream {
115
+ [OutputType([IO.FileStream])]
116
+ param(
117
+ [Parameter(Mandatory)]
118
+ [string]$Path,
119
+ [Parameter(Mandatory)]
120
+ [string]$ParameterName
121
+ )
122
+
123
+ $nativeMethods = Get-WaveformNativeMethods
124
+ $handle = $nativeMethods::CreateFile(
125
+ $Path,
126
+ ($nativeMethods::GenericWrite -bor $nativeMethods::Delete),
127
+ 0,
128
+ [IntPtr]::Zero,
129
+ $nativeMethods::CreateNew,
130
+ $nativeMethods::FileAttributeNormal,
131
+ [IntPtr]::Zero
132
+ )
133
+ if ($handle.IsInvalid) {
134
+ $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
135
+ $handle.Dispose()
136
+ throw "$ParameterName could not be exclusively created; refusing to overwrite: $Path (Win32 error $errorCode)"
137
+ }
138
+
139
+ try {
140
+ return [IO.FileStream]::new($handle, [IO.FileAccess]::Write)
141
+ }
142
+ catch {
143
+ $handle.Dispose()
144
+ throw
145
+ }
146
+ }
147
+
148
+ function Set-DeletePendingForOpenStream {
149
+ [OutputType([bool])]
150
+ param([Parameter(Mandatory)][IO.FileStream]$Stream)
151
+
152
+ $nativeMethods = Get-WaveformNativeMethods
153
+ $disposition = [AgentVerificationLab.WaveformNativeMethods+FILE_DISPOSITION_INFO]::new()
154
+ $disposition.DeleteFile = $true
155
+ return $nativeMethods::SetFileInformationByHandle(
156
+ $Stream.SafeFileHandle,
157
+ $nativeMethods::FileDispositionInfo,
158
+ [ref]$disposition,
159
+ [uint32][Runtime.InteropServices.Marshal]::SizeOf($disposition)
160
+ )
161
+ }
162
+
163
+ function Get-MergedSilenceIntervals {
164
+ [OutputType([object[]])]
165
+ param(
166
+ [Parameter(Mandatory)]
167
+ [string[]]$Diagnostics,
168
+ [Parameter(Mandatory)]
169
+ [double]$AnalysisDurationSeconds,
170
+ [Parameter(Mandatory)]
171
+ [double]$MergeGapSeconds
172
+ )
173
+
174
+ $rawIntervals = [System.Collections.Generic.List[object]]::new()
175
+ $openStart = $null
176
+ foreach ($line in $Diagnostics) {
177
+ $text = $line.ToString()
178
+ $startMatch = [regex]::Match($text, 'silence_start:\s*(?<time>-?[0-9]+(?:\.[0-9]+)?)')
179
+ if ($startMatch.Success) {
180
+ $openStart = ConvertTo-InvariantDouble -Text $startMatch.Groups['time'].Value
181
+ continue
182
+ }
183
+
184
+ $endMatch = [regex]::Match($text, 'silence_end:\s*(?<time>-?[0-9]+(?:\.[0-9]+)?)')
185
+ if ($endMatch.Success -and $null -ne $openStart) {
186
+ $end = ConvertTo-InvariantDouble -Text $endMatch.Groups['time'].Value
187
+ if ($end -ge $openStart) {
188
+ [void]$rawIntervals.Add([pscustomobject]@{ start = [double]$openStart; end = $end })
189
+ }
190
+ $openStart = $null
191
+ }
192
+ }
193
+
194
+ if ($null -ne $openStart -and $AnalysisDurationSeconds -ge $openStart) {
195
+ [void]$rawIntervals.Add([pscustomobject]@{ start = [double]$openStart; end = $AnalysisDurationSeconds })
196
+ }
197
+
198
+ $merged = [System.Collections.Generic.List[object]]::new()
199
+ foreach ($interval in @($rawIntervals | Sort-Object start, end)) {
200
+ if ($merged.Count -eq 0) {
201
+ [void]$merged.Add([pscustomobject]@{ start = $interval.start; end = $interval.end })
202
+ continue
203
+ }
204
+
205
+ $last = $merged[$merged.Count - 1]
206
+ if ($interval.start -le ($last.end + $MergeGapSeconds)) {
207
+ if ($interval.end -gt $last.end) {
208
+ $last.end = $interval.end
209
+ }
210
+ continue
211
+ }
212
+
213
+ [void]$merged.Add([pscustomobject]@{ start = $interval.start; end = $interval.end })
214
+ }
215
+
216
+ return @($merged)
217
+ }
218
+
219
+ if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) {
220
+ throw "Input media file does not exist or is not a file: $inputPath"
221
+ }
222
+ if ($Width -lt 64 -or $Width -gt 4096) {
223
+ throw 'Width must be between 64 and 4096.'
224
+ }
225
+ if ($Height -lt 64 -or $Height -gt 2160) {
226
+ throw 'Height must be between 64 and 2160.'
227
+ }
228
+ if ([double]::IsNaN($SilenceDb) -or [double]::IsInfinity($SilenceDb) -or $SilenceDb -lt -100 -or $SilenceDb -gt 0) {
229
+ throw 'SilenceDb must be between -100 and 0 dB.'
230
+ }
231
+ if ([double]::IsNaN($MinSilenceSeconds) -or [double]::IsInfinity($MinSilenceSeconds) -or $MinSilenceSeconds -lt 0.02 -or $MinSilenceSeconds -gt 30) {
232
+ throw 'MinSilenceSeconds must be between 0.02 and 30 seconds.'
233
+ }
234
+
235
+ $resolvedInput = [IO.Path]::GetFullPath($inputPath)
236
+ $resolvedOutputPng = Resolve-NewOutputPath -Path $OutputPng -ExpectedExtension '.png' -ParameterName 'OutputPng'
237
+ $resolvedOutputEventsJson = Resolve-NewOutputPath -Path $OutputEventsJson -ExpectedExtension '.json' -ParameterName 'OutputEventsJson'
238
+ if ($resolvedOutputPng -ieq $resolvedOutputEventsJson) {
239
+ throw 'OutputPng and OutputEventsJson must be different paths.'
240
+ }
241
+ if ($resolvedInput -ieq $resolvedOutputPng -or $resolvedInput -ieq $resolvedOutputEventsJson) {
242
+ throw 'Output paths must not replace the input media file.'
243
+ }
244
+
245
+ $ffmpegPath = Get-FfmpegPath
246
+ $invariantCulture = [Globalization.CultureInfo]::InvariantCulture
247
+ $sampleRate = 16000
248
+ $windowMilliseconds = 20
249
+ $windowSamples = [int]($sampleRate * $windowMilliseconds / 1000)
250
+ $maximumAnalysisSeconds = 300
251
+ $widthText = $Width.ToString($invariantCulture)
252
+ $heightText = $Height.ToString($invariantCulture)
253
+ $silenceDbText = $SilenceDb.ToString('G17', $invariantCulture)
254
+ $minSilenceText = $MinSilenceSeconds.ToString('G17', $invariantCulture)
255
+
256
+ $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) 'agent-verification-lab'
257
+ New-Item -ItemType Directory -Path $temporaryRoot -Force -ErrorAction Stop | Out-Null
258
+ $stagingPng = Join-Path $temporaryRoot ('waveform-' + [Guid]::NewGuid().ToString('N') + '.png')
259
+ $outputPngStream = $null
260
+ $eventsStream = $null
261
+ $stagingReadStream = $null
262
+ $pngReserved = $false
263
+ $eventsReserved = $false
264
+ $completed = $false
265
+ $temporaryPcm = $null
266
+ try {
267
+ $outputPngStream = Open-NewOutputStream -Path $resolvedOutputPng -ParameterName 'OutputPng'
268
+ $pngReserved = $true
269
+ $eventsStream = Open-NewOutputStream -Path $resolvedOutputEventsJson -ParameterName 'OutputEventsJson'
270
+ $eventsReserved = $true
271
+
272
+ $waveformArguments = [string[]]@(
273
+ '-i', $resolvedInput,
274
+ '-filter_complex', "[0:a:0]aformat=channel_layouts=mono,showwavespic=s=${widthText}x${heightText}:colors=0x40c4ff[wave]",
275
+ '-map', '[wave]',
276
+ '-frames:v', '1',
277
+ '-c:v', 'png',
278
+ $stagingPng
279
+ )
280
+ try {
281
+ Invoke-Ffmpeg -FfmpegPath $ffmpegPath -Arguments $waveformArguments -Operation "waveform rendering for $resolvedInput"
282
+ }
283
+ catch {
284
+ Write-Warning "Waveform rendering failed; staging path was not acquired and was left untouched: $stagingPng"
285
+ throw
286
+ }
287
+ try {
288
+ $stagingReadStream = [IO.File]::Open($stagingPng, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
289
+ }
290
+ catch {
291
+ Write-Warning "Could not open the retained staging PNG for reading; leaving it in TEMP: $stagingPng"
292
+ throw
293
+ }
294
+
295
+ $temporaryPcm = Join-Path $temporaryRoot ('waveform-' + [Guid]::NewGuid().ToString('N') + '.pcm')
296
+
297
+ $pcmArguments = [string[]]@(
298
+ '-i', $resolvedInput,
299
+ '-map', '0:a:0',
300
+ '-t', $maximumAnalysisSeconds.ToString($invariantCulture),
301
+ '-ac', '1',
302
+ '-ar', $sampleRate.ToString($invariantCulture),
303
+ '-f', 's16le',
304
+ '-y', $temporaryPcm
305
+ )
306
+ Invoke-Ffmpeg -FfmpegPath $ffmpegPath -Arguments $pcmArguments -Operation "bounded mono PCM decoding for $resolvedInput"
307
+ $pcmBytes = [IO.File]::ReadAllBytes($temporaryPcm)
308
+ if ($pcmBytes.Length -eq 0 -or ($pcmBytes.Length % 2) -ne 0) {
309
+ throw "Decoded PCM is empty or malformed: $temporaryPcm"
310
+ }
311
+
312
+ $sampleCount = [int]($pcmBytes.Length / 2)
313
+ $analysisDurationSeconds = $sampleCount / [double]$sampleRate
314
+ $events = [System.Collections.Generic.List[object]]::new()
315
+ $active = $false
316
+ $peakAmplitude = 0.0
317
+ $peakTime = 0.0
318
+
319
+ for ($windowStartSample = 0; $windowStartSample -lt $sampleCount; $windowStartSample += $windowSamples) {
320
+ $windowEndSample = [Math]::Min($windowStartSample + $windowSamples, $sampleCount)
321
+ $sumSquares = 0.0
322
+ $windowPeak = 0.0
323
+ $windowPeakSample = $windowStartSample
324
+ for ($sampleIndex = $windowStartSample; $sampleIndex -lt $windowEndSample; $sampleIndex++) {
325
+ $sample = [BitConverter]::ToInt16($pcmBytes, $sampleIndex * 2) / 32768.0
326
+ $absoluteSample = [Math]::Abs($sample)
327
+ $sumSquares += $sample * $sample
328
+ if ($absoluteSample -gt $windowPeak) {
329
+ $windowPeak = $absoluteSample
330
+ $windowPeakSample = $sampleIndex
331
+ }
332
+ }
333
+
334
+ $windowLength = $windowEndSample - $windowStartSample
335
+ $rms = [Math]::Sqrt($sumSquares / $windowLength)
336
+ $rmsDb = if ($rms -gt 0) { 20 * [Math]::Log10($rms) } else { -120.0 }
337
+ $windowIsActive = $rmsDb -ge $SilenceDb
338
+ $windowTime = $windowStartSample / [double]$sampleRate
339
+
340
+ if ($windowIsActive) {
341
+ if (-not $active) {
342
+ [void]$events.Add([pscustomobject]@{
343
+ type = 'onset'
344
+ time = [Math]::Round($windowTime, 6)
345
+ amplitude = [Math]::Round($rms, 6)
346
+ })
347
+ $active = $true
348
+ $peakAmplitude = $windowPeak
349
+ $peakTime = $windowPeakSample / [double]$sampleRate
350
+ }
351
+ elseif ($windowPeak -gt $peakAmplitude) {
352
+ $peakAmplitude = $windowPeak
353
+ $peakTime = $windowPeakSample / [double]$sampleRate
354
+ }
355
+ }
356
+ elseif ($active) {
357
+ [void]$events.Add([pscustomobject]@{
358
+ type = 'peak'
359
+ time = [Math]::Round($peakTime, 6)
360
+ amplitude = [Math]::Round($peakAmplitude, 6)
361
+ })
362
+ $active = $false
363
+ }
364
+ }
365
+ if ($active) {
366
+ [void]$events.Add([pscustomobject]@{
367
+ type = 'peak'
368
+ time = [Math]::Round($peakTime, 6)
369
+ amplitude = [Math]::Round($peakAmplitude, 6)
370
+ })
371
+ }
372
+
373
+ $silenceArguments = [string[]]@(
374
+ '-i', $resolvedInput,
375
+ '-map', '0:a:0',
376
+ '-t', $maximumAnalysisSeconds.ToString($invariantCulture),
377
+ '-af', "aformat=channel_layouts=mono,silencedetect=n=${silenceDbText}dB:d=${minSilenceText}",
378
+ '-f', 'null', '-'
379
+ )
380
+ $silenceOutput = & $ffmpegPath '-hide_banner' '-loglevel' 'info' '-nostdin' @silenceArguments 2>&1
381
+ $silenceExitCode = $LASTEXITCODE
382
+ if ($silenceExitCode -ne 0) {
383
+ $diagnostics = ($silenceOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
384
+ throw "FFmpeg silence detection failed with exit code $silenceExitCode.$([Environment]::NewLine)$diagnostics"
385
+ }
386
+
387
+ foreach ($interval in Get-MergedSilenceIntervals -Diagnostics @($silenceOutput | ForEach-Object { $_.ToString() }) -AnalysisDurationSeconds $analysisDurationSeconds -MergeGapSeconds ($windowMilliseconds / 1000.0)) {
388
+ [void]$events.Add([pscustomobject]@{ type = 'silence_start'; time = [Math]::Round([double]$interval.start, 6) })
389
+ [void]$events.Add([pscustomobject]@{ type = 'silence_end'; time = [Math]::Round([double]$interval.end, 6) })
390
+ }
391
+
392
+ $sortedEvents = @($events | Sort-Object @{ Expression = { [double]$_.time }; Ascending = $true }, @{ Expression = { $_.type }; Ascending = $true })
393
+ $document = [pscustomobject]@{
394
+ schema_version = 'agent-verification-lab.audio-events.v1'
395
+ sample_rate = $sampleRate
396
+ events = $sortedEvents
397
+ }
398
+ $stagingReadStream.CopyTo($outputPngStream)
399
+ $outputPngStream.Flush($true)
400
+ $jsonBytes = [Text.UTF8Encoding]::new($false).GetBytes(($document | ConvertTo-Json -Depth 5))
401
+ $eventsStream.Write($jsonBytes, 0, $jsonBytes.Length)
402
+ $eventsStream.Flush($true)
403
+ $completed = $true
404
+ }
405
+ finally {
406
+ try {
407
+ if (-not $completed) {
408
+ if ($pngReserved -and $null -ne $outputPngStream) {
409
+ if (-not (Set-DeletePendingForOpenStream -Stream $outputPngStream)) {
410
+ Write-Warning "Could not mark the tool-owned OutputPng reservation for deletion; leaving partial file: $resolvedOutputPng"
411
+ }
412
+ }
413
+ if ($eventsReserved -and $null -ne $eventsStream) {
414
+ if (-not (Set-DeletePendingForOpenStream -Stream $eventsStream)) {
415
+ Write-Warning "Could not mark the tool-owned OutputEventsJson reservation for deletion; leaving partial file: $resolvedOutputEventsJson"
416
+ }
417
+ }
418
+ }
419
+ }
420
+ finally {
421
+ try {
422
+ if ($null -ne $stagingReadStream) {
423
+ $stagingReadStream.Dispose()
424
+ }
425
+ }
426
+ finally {
427
+ try {
428
+ if ($null -ne $outputPngStream) {
429
+ $outputPngStream.Dispose()
430
+ }
431
+ }
432
+ finally {
433
+ try {
434
+ if ($null -ne $eventsStream) {
435
+ $eventsStream.Dispose()
436
+ }
437
+ }
438
+ finally {
439
+ if ($null -ne $temporaryPcm -and (Test-Path -LiteralPath $temporaryPcm -PathType Leaf)) {
440
+ Remove-Item -LiteralPath $temporaryPcm -Force -ErrorAction SilentlyContinue
441
+ }
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ Write-Output "WAVEFORM_PNG=$resolvedOutputPng"
449
+ Write-Output "EVENTS_JSON=$resolvedOutputEventsJson"
450
+ Write-Output "STAGING_PNG=$stagingPng"