@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,97 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [string]$App,
4
+ [string]$ProcessName,
5
+ [string]$WindowTitle,
6
+ [string]$ClassName,
7
+ [Nullable[int]]$ProcessId,
8
+ [Nullable[long]]$Hwnd,
9
+ [Nullable[int]]$Width,
10
+ [Nullable[int]]$Height,
11
+ [double]$Duration = 5,
12
+ [int]$Fps = 5,
13
+ [switch]$Frames,
14
+ [int]$MaxEdge = 1280,
15
+ [string]$OutputPath,
16
+ [string]$RunDirectory
17
+ )
18
+
19
+ Set-StrictMode -Version Latest
20
+ $ErrorActionPreference = 'Stop'
21
+ . (Join-Path $PSScriptRoot 'common.ps1')
22
+ . (Join-Path $PSScriptRoot 'winapp-common.ps1')
23
+
24
+ try {
25
+ $version = Get-WinAppVersionInfo
26
+ Assert-WinAppCaptureReady -VersionInfo $version
27
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
28
+ if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $run.RunDirectory 'recording.mp4' }
29
+ $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
30
+ $metadataOutputPath = Join-Path $run.RunDirectory 'recording.json'
31
+ $representativeOutputPath = Join-Path $run.RunDirectory 'representative-frames.json'
32
+ Assert-OutputAvailable -Path $resolvedOutputPath
33
+ Assert-OutputAvailable -Path $metadataOutputPath
34
+ if ($Frames) { Assert-OutputAvailable -Path $representativeOutputPath }
35
+ $expectedFramesDirectory = Join-Path (Split-Path -Parent $resolvedOutputPath) (([IO.Path]::GetFileNameWithoutExtension($resolvedOutputPath)) + '.frames')
36
+ if ($Frames -and (Test-Path -LiteralPath $expectedFramesDirectory)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message "Frame output already exists: $expectedFramesDirectory") }
37
+ $target = Get-WinAppWindowTarget -Executable $version.executable -App $App -ProcessName $ProcessName -WindowTitle $WindowTitle -ClassName $ClassName -ProcessId $ProcessId -Hwnd $Hwnd -Width $Width -Height $Height
38
+ $arguments = New-WinAppRecordArguments -Hwnd $target.hwnd -OutputPath $resolvedOutputPath -Duration $Duration -Fps $Fps -Frames:$Frames -MaxEdge $MaxEdge
39
+ $response = Invoke-WinAppJson -Executable $version.executable -Arguments $arguments -Operation record
40
+ Assert-WinAppMp4 -Path $resolvedOutputPath
41
+ $recordWidth = [int](Get-WinAppPropertyValue $response.data 'width' 0)
42
+ $recordHeight = [int](Get-WinAppPropertyValue $response.data 'height' 0)
43
+ if ($recordWidth -le 0 -or $recordHeight -le 0) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'WinApp CLI returned zero recording dimensions.') }
44
+ if ([string](Get-WinAppPropertyValue $response.data 'codec') -ne 'h264') { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'WinApp CLI did not report an H.264 recording.') }
45
+
46
+ $artifacts = [Collections.Generic.List[object]]::new()
47
+ [void]$artifacts.Add([pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'video/mp4' })
48
+ $representative = @()
49
+ $frameArtifacts = Get-WinAppPropertyValue $response.data 'frameArtifacts'
50
+ if ($Frames) {
51
+ if ($null -eq $frameArtifacts) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'Frame artifacts were requested but not reported.') }
52
+ $framesDirectory = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'directory'))
53
+ $manifestPath = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'manifest'))
54
+ $indexPath = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'index'))
55
+ if (-not (Test-Path -LiteralPath $framesDirectory -PathType Container) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf) -or -not (Test-Path -LiteralPath $indexPath -PathType Leaf)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'One or more requested frame artifacts are missing.') }
56
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop
57
+ if ([string](Get-WinAppPropertyValue $manifest 'status') -ne 'complete') { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'Frame manifest is not complete.') }
58
+ $samples = @(Get-Content -LiteralPath $indexPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_ | ConvertFrom-Json -ErrorAction Stop })
59
+ if ($samples.Count -eq 0) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'frames.ndjson contains no samples.') }
60
+ $representative = @(Select-WinAppRepresentativeFrames -Samples $samples -FramesRoot $framesDirectory)
61
+ foreach ($frame in $representative) { if (-not (Test-Path -LiteralPath $frame.path -PathType Leaf)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message "Representative frame is missing: $($frame.path)") } }
62
+ $representativePath = Write-WinAppJsonFile -Value ([pscustomobject]@{ strategy = 'first_middle_last'; samples = $representative }) -Path $representativeOutputPath
63
+ [void]$artifacts.Add([pscustomobject]@{ role = 'frame-directory'; path = $framesDirectory; mime = 'application/x-directory' })
64
+ [void]$artifacts.Add([pscustomobject]@{ role = 'frame-manifest'; path = $manifestPath; mime = 'application/json' })
65
+ [void]$artifacts.Add([pscustomobject]@{ role = 'frame-index'; path = $indexPath; mime = 'application/x-ndjson' })
66
+ [void]$artifacts.Add([pscustomobject]@{ role = 'representative-frames'; path = $representativePath; mime = 'application/json' })
67
+ }
68
+
69
+ $commandResult = [pscustomobject]@{ exit_code = $response.process.exit_code; arguments = @($arguments) }
70
+ $metadata = New-WinAppOperationMetadata -Operation 'record' -Version $version.actual -VersionClassification $version.classification -Target $target -ArtifactPath $resolvedOutputPath -Width $recordWidth -Height $recordHeight -CommandResult $commandResult
71
+ $metadata | Add-Member -NotePropertyName recording -NotePropertyValue ([pscustomobject]@{
72
+ codec = 'h264'; duration_sec = [double](Get-WinAppPropertyValue $response.data 'durationSec' 0); fps = [double](Get-WinAppPropertyValue $response.data 'fps' 0); capture_mode = [string](Get-WinAppPropertyValue $response.data 'mode'); frames_enabled = [bool]$Frames; representative_frames = $representative
73
+ })
74
+ $targetPath = Write-WinAppTargetDocument -Path (Join-Path $run.RunDirectory 'desktop-window.json') -Target $target -VersionInfo $version
75
+ $metadataPath = Write-WinAppJsonFile -Value $metadata -Path $metadataOutputPath
76
+ [void]$artifacts.Add([pscustomobject]@{ role = 'desktop-window'; path = $targetPath; mime = 'application/json' })
77
+ [void]$artifacts.Add([pscustomobject]@{ role = 'recording-metadata'; path = $metadataPath; mime = 'application/json' })
78
+ $warnings = @()
79
+ if ($version.classification -eq 'UNTESTED_NEWER') { $warnings += 'WINAPP_VERSION_UNTESTED' }
80
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
81
+ schema_version = 'agent-verification-lab.result.v1'
82
+ status = 'ok'
83
+ kind = $run.Kind
84
+ run_id = $run.RunId
85
+ run_directory = $run.RunDirectory
86
+ artifacts = @($artifacts)
87
+ backend = $metadata.backend
88
+ target = $target
89
+ recording = $metadata.recording
90
+ warnings = $warnings
91
+ })
92
+ Write-VisualResult -RunDirectory $run.RunDirectory -OutputPath $resolvedOutputPath -Kind 'desktop-recording' -ResultJsonPath $resultJson
93
+ }
94
+ catch {
95
+ Write-WinAppFailure -ErrorRecord $_ -Operation record
96
+ exit 1
97
+ }
@@ -0,0 +1,65 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [string]$App,
4
+ [string]$ProcessName,
5
+ [string]$WindowTitle,
6
+ [string]$ClassName,
7
+ [Nullable[int]]$ProcessId,
8
+ [Nullable[long]]$Hwnd,
9
+ [Nullable[int]]$Width,
10
+ [Nullable[int]]$Height,
11
+ [string]$OutputPath,
12
+ [string]$RunDirectory
13
+ )
14
+
15
+ Set-StrictMode -Version Latest
16
+ $ErrorActionPreference = 'Stop'
17
+ . (Join-Path $PSScriptRoot 'common.ps1')
18
+ . (Join-Path $PSScriptRoot 'winapp-common.ps1')
19
+
20
+ try {
21
+ $version = Get-WinAppVersionInfo
22
+ Assert-WinAppCaptureReady -VersionInfo $version
23
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
24
+ if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $run.RunDirectory 'screenshot.png' }
25
+ $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
26
+ $metadataOutputPath = Join-Path $run.RunDirectory 'screenshot.json'
27
+ Assert-OutputAvailable -Path $resolvedOutputPath
28
+ Assert-OutputAvailable -Path $metadataOutputPath
29
+ $target = Get-WinAppWindowTarget -Executable $version.executable -App $App -ProcessName $ProcessName -WindowTitle $WindowTitle -ClassName $ClassName -ProcessId $ProcessId -Hwnd $Hwnd -Width $Width -Height $Height
30
+ $arguments = New-WinAppScreenshotArguments -Hwnd $target.hwnd -OutputPath $resolvedOutputPath
31
+ $response = Invoke-WinAppJson -Executable $version.executable -Arguments $arguments -Operation screenshot
32
+ $dimensions = Get-PngDimensions -Path $resolvedOutputPath
33
+ $reportedPath = [string](Get-WinAppPropertyValue $response.data 'filePath')
34
+ if (-not [string]::IsNullOrWhiteSpace($reportedPath) -and [IO.Path]::GetFullPath($reportedPath) -ne $resolvedOutputPath) {
35
+ throw (New-WinAppException -Code 'CAPTURE_FAILED' -Message 'WinApp CLI reported a different screenshot output path.')
36
+ }
37
+ $associated = @(Get-WinAppPropertyValue $response.data 'windows' @())
38
+ $commandResult = [pscustomobject]@{ exit_code = $response.process.exit_code; arguments = @($arguments) }
39
+ $metadata = New-WinAppOperationMetadata -Operation 'screenshot' -Version $version.actual -VersionClassification $version.classification -Target $target -ArtifactPath $resolvedOutputPath -Width $dimensions.width -Height $dimensions.height -AssociatedWindows $associated -CommandResult $commandResult
40
+ $targetPath = Write-WinAppTargetDocument -Path (Join-Path $run.RunDirectory 'desktop-window.json') -Target $target -VersionInfo $version
41
+ $metadataPath = Write-WinAppJsonFile -Value $metadata -Path $metadataOutputPath
42
+ $warnings = @()
43
+ if ($version.classification -eq 'UNTESTED_NEWER') { $warnings += 'WINAPP_VERSION_UNTESTED' }
44
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
45
+ schema_version = 'agent-verification-lab.result.v1'
46
+ status = 'ok'
47
+ kind = $run.Kind
48
+ run_id = $run.RunId
49
+ run_directory = $run.RunDirectory
50
+ artifacts = @(
51
+ [pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'image/png' },
52
+ [pscustomobject]@{ role = 'desktop-window'; path = $targetPath; mime = 'application/json' },
53
+ [pscustomobject]@{ role = 'screenshot-metadata'; path = $metadataPath; mime = 'application/json' }
54
+ )
55
+ backend = $metadata.backend
56
+ target = $target
57
+ capture = [pscustomobject]@{ status = 'PASS'; semantic_verification = 'REQUIRES_VISUAL_INSPECTION'; mode = 'default_window_capture'; capture_screen = $false }
58
+ warnings = $warnings
59
+ })
60
+ Write-VisualResult -RunDirectory $run.RunDirectory -OutputPath $resolvedOutputPath -Kind 'desktop-screenshot' -ResultJsonPath $resultJson
61
+ }
62
+ catch {
63
+ Write-WinAppFailure -ErrorRecord $_ -Operation screenshot
64
+ exit 1
65
+ }
@@ -0,0 +1,249 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory)]
4
+ [ValidateNotNullOrEmpty()]
5
+ [string]$AudioEventsJson,
6
+ [Parameter(Mandatory)]
7
+ [double]$VisualEventSeconds,
8
+ [string]$SpeechJson,
9
+ [Nullable[double]]$GestureSeconds,
10
+ [ValidateRange(1, 1000)]
11
+ [double]$ToleranceMilliseconds = 60,
12
+ [string]$OutputJson
13
+ )
14
+
15
+ Set-StrictMode -Version Latest
16
+
17
+ $toleranceMillisecondsWasExplicit = $PSBoundParameters.ContainsKey('ToleranceMilliseconds')
18
+ $signConvention = 'audio-minus-visual; audio-late is positive and audio-early is negative'
19
+
20
+ function Get-PropertyOrNull {
21
+ [OutputType([object])]
22
+ param(
23
+ [AllowNull()][object]$Object,
24
+ [Parameter(Mandatory)][string]$Name
25
+ )
26
+
27
+ if ($null -eq $Object) {
28
+ return $null
29
+ }
30
+
31
+ $property = $Object.PSObject.Properties[$Name]
32
+ if ($null -eq $property) {
33
+ return $null
34
+ }
35
+
36
+ return $property.Value
37
+ }
38
+
39
+ function ConvertTo-FiniteSeconds {
40
+ [OutputType([double])]
41
+ param(
42
+ [Parameter(Mandatory)][AllowNull()][object]$Value,
43
+ [Parameter(Mandatory)][string]$Description
44
+ )
45
+
46
+ try {
47
+ $seconds = [Convert]::ToDouble($Value, [Globalization.CultureInfo]::InvariantCulture)
48
+ }
49
+ catch {
50
+ throw "$Description must be a numeric timestamp in seconds."
51
+ }
52
+
53
+ if ([double]::IsNaN($seconds) -or [double]::IsInfinity($seconds)) {
54
+ throw "$Description must be a finite timestamp in seconds."
55
+ }
56
+
57
+ return $seconds
58
+ }
59
+
60
+ function Read-JsonDocument {
61
+ [OutputType([object])]
62
+ param(
63
+ [Parameter(Mandatory)][string]$Path,
64
+ [Parameter(Mandatory)][string]$Description
65
+ )
66
+
67
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
68
+ throw "$Description does not exist or is not a file: $Path"
69
+ }
70
+
71
+ try {
72
+ return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
73
+ }
74
+ catch {
75
+ throw "$Description is not valid JSON: $Path. $($_.Exception.Message)"
76
+ }
77
+ }
78
+
79
+ function New-TimestampMeasurement {
80
+ [OutputType([pscustomobject])]
81
+ param([AllowNull()][object]$Seconds)
82
+
83
+ if ($null -eq $Seconds) {
84
+ return $null
85
+ }
86
+
87
+ return [pscustomobject]@{
88
+ seconds = [Math]::Round([double]$Seconds, 6)
89
+ milliseconds = [Math]::Round(([double]$Seconds) * 1000, 3)
90
+ }
91
+ }
92
+
93
+ function New-OffsetMeasurement {
94
+ [OutputType([pscustomobject])]
95
+ param(
96
+ [AllowNull()][object]$MeasuredSeconds,
97
+ [Parameter(Mandatory)][double]$VisualSeconds
98
+ )
99
+
100
+ if ($null -eq $MeasuredSeconds) {
101
+ return $null
102
+ }
103
+
104
+ $offsetSeconds = ([double]$MeasuredSeconds) - $VisualSeconds
105
+ return [pscustomobject]@{
106
+ seconds = [Math]::Round($offsetSeconds, 6)
107
+ milliseconds = [Math]::Round($offsetSeconds * 1000, 3)
108
+ }
109
+ }
110
+
111
+ function Write-NewOutputJson {
112
+ [CmdletBinding()]
113
+ param(
114
+ [Parameter(Mandatory)][string]$Path,
115
+ [Parameter(Mandatory)][string]$Json
116
+ )
117
+
118
+ $stream = $null
119
+ try {
120
+ # CreateNew is the final authority: it also rejects a file created after validation.
121
+ $stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
122
+ }
123
+ catch [IO.IOException] {
124
+ throw "OutputJson already exists or could not be exclusively created: $Path"
125
+ }
126
+
127
+ try {
128
+ $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Json)
129
+ $stream.Write($bytes, 0, $bytes.Length)
130
+ $stream.Flush($true)
131
+ }
132
+ finally {
133
+ $stream.Dispose()
134
+ }
135
+ }
136
+
137
+ $audioEventsPath = [IO.Path]::GetFullPath($AudioEventsJson)
138
+ $visualSeconds = ConvertTo-FiniteSeconds -Value $VisualEventSeconds -Description 'VisualEventSeconds'
139
+ $resolvedOutputJson = $null
140
+ if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
141
+ $resolvedOutputJson = [IO.Path]::GetFullPath($OutputJson)
142
+ $outputDirectory = Split-Path -Parent $resolvedOutputJson
143
+ if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
144
+ throw "OutputJson directory does not exist: $outputDirectory"
145
+ }
146
+ }
147
+
148
+ $audioDocument = Read-JsonDocument -Path $audioEventsPath -Description 'AudioEventsJson'
149
+ if ((Get-PropertyOrNull -Object $audioDocument -Name 'schema_version') -ne 'agent-verification-lab.audio-events.v1') {
150
+ throw "AudioEventsJson must use schema_version 'agent-verification-lab.audio-events.v1': $audioEventsPath"
151
+ }
152
+
153
+ $onsets = [System.Collections.Generic.List[double]]::new()
154
+ foreach ($event in @(Get-PropertyOrNull -Object $audioDocument -Name 'events')) {
155
+ if ($null -eq $event -or (Get-PropertyOrNull -Object $event -Name 'type') -ne 'onset') {
156
+ continue
157
+ }
158
+
159
+ $eventTime = Get-PropertyOrNull -Object $event -Name 'time'
160
+ if ($null -eq $eventTime) {
161
+ throw "Audio onset in AudioEventsJson has no time: $audioEventsPath"
162
+ }
163
+ [void]$onsets.Add((ConvertTo-FiniteSeconds -Value $eventTime -Description 'Audio onset time'))
164
+ }
165
+
166
+ [Nullable[double]]$audioOnsetSeconds = $null
167
+ if ($onsets.Count -gt 0) {
168
+ $audioOnsetSeconds = @($onsets | Sort-Object)[0]
169
+ }
170
+
171
+ [Nullable[double]]$speechFirstWordSeconds = $null
172
+ $speechStatus = 'NOT_SUPPLIED'
173
+ if (-not [string]::IsNullOrWhiteSpace($SpeechJson)) {
174
+ $speechPath = [IO.Path]::GetFullPath($SpeechJson)
175
+ $speechDocument = Read-JsonDocument -Path $speechPath -Description 'SpeechJson'
176
+ if ((Get-PropertyOrNull -Object $speechDocument -Name 'schema_version') -ne 'agent-verification-lab.speech.v1') {
177
+ throw "SpeechJson must use schema_version 'agent-verification-lab.speech.v1': $speechPath"
178
+ }
179
+
180
+ $wordStarts = [System.Collections.Generic.List[double]]::new()
181
+ foreach ($word in @(Get-PropertyOrNull -Object $speechDocument -Name 'words')) {
182
+ if ($null -eq $word) {
183
+ continue
184
+ }
185
+
186
+ $wordStart = Get-PropertyOrNull -Object $word -Name 'start'
187
+ if ($null -eq $wordStart) {
188
+ throw "SpeechJson word has no start timestamp: $speechPath"
189
+ }
190
+ [void]$wordStarts.Add((ConvertTo-FiniteSeconds -Value $wordStart -Description 'Speech word start'))
191
+ }
192
+
193
+ if ($wordStarts.Count -gt 0) {
194
+ $speechFirstWordSeconds = @($wordStarts | Sort-Object)[0]
195
+ $speechStatus = 'MEASURED'
196
+ }
197
+ else {
198
+ $speechStatus = 'NO_WORDS'
199
+ }
200
+ }
201
+
202
+ [Nullable[double]]$gestureTimestamp = $null
203
+ if ($null -ne $GestureSeconds) {
204
+ $gestureTimestamp = ConvertTo-FiniteSeconds -Value $GestureSeconds -Description 'GestureSeconds'
205
+ }
206
+
207
+ $audioOffset = New-OffsetMeasurement -MeasuredSeconds $audioOnsetSeconds -VisualSeconds $visualSeconds
208
+ $status = if ($null -eq $audioOffset) {
209
+ 'NO_AUDIO_ONSET'
210
+ }
211
+ elseif ([Math]::Abs([double]$audioOffset.milliseconds) -le $ToleranceMilliseconds) {
212
+ 'IN_TOLERANCE'
213
+ }
214
+ else {
215
+ 'OUT_OF_TOLERANCE'
216
+ }
217
+ $toleranceText = $ToleranceMilliseconds.ToString('0.###', [Globalization.CultureInfo]::InvariantCulture)
218
+ $toleranceBasis = if (-not $toleranceMillisecondsWasExplicit -and $ToleranceMilliseconds -eq 60) {
219
+ '60 ms default: 20 ms audio analysis window plus conservative timestamp quantization allowance.'
220
+ }
221
+ else {
222
+ "Caller-selected tolerance: ${toleranceText} ms."
223
+ }
224
+
225
+ $result = [pscustomobject]@{
226
+ schema_version = 'agent-verification-lab.sync.v1'
227
+ status = $status
228
+ sign_convention = $signConvention
229
+ tolerance = [pscustomobject]@{
230
+ seconds = [Math]::Round($ToleranceMilliseconds / 1000.0, 6)
231
+ milliseconds = [Math]::Round($ToleranceMilliseconds, 3)
232
+ basis = $toleranceBasis
233
+ }
234
+ visual_event = New-TimestampMeasurement -Seconds $visualSeconds
235
+ audio_onset = New-TimestampMeasurement -Seconds $audioOnsetSeconds
236
+ audio_minus_visual = $audioOffset
237
+ speech_status = $speechStatus
238
+ speech_first_word = New-TimestampMeasurement -Seconds $speechFirstWordSeconds
239
+ speech_first_word_minus_visual = New-OffsetMeasurement -MeasuredSeconds $speechFirstWordSeconds -VisualSeconds $visualSeconds
240
+ gesture = New-TimestampMeasurement -Seconds $gestureTimestamp
241
+ gesture_minus_visual = New-OffsetMeasurement -MeasuredSeconds $gestureTimestamp -VisualSeconds $visualSeconds
242
+ }
243
+
244
+ $json = $result | ConvertTo-Json -Depth 6
245
+ if ($null -ne $resolvedOutputJson) {
246
+ Write-NewOutputJson -Path $resolvedOutputJson -Json $json
247
+ }
248
+
249
+ Write-Output $json
@@ -0,0 +1,79 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory)]
4
+ [ValidateNotNullOrEmpty()]
5
+ [string]$InputVideo,
6
+ [double]$Interval = 0.5,
7
+ [int]$MaxFrames = 20,
8
+ [string]$OutputDirectory,
9
+ [string]$RunDirectory,
10
+ [switch]$Overwrite
11
+ )
12
+
13
+ $common = Join-Path $PSScriptRoot 'common.ps1'
14
+ . $common
15
+
16
+ if (-not (Test-Path -LiteralPath $InputVideo -PathType Leaf)) {
17
+ throw "Input video does not exist or is not a file: $InputVideo"
18
+ }
19
+
20
+ if ([double]::IsNaN($Interval) -or [double]::IsInfinity($Interval) -or $Interval -le 0) {
21
+ throw 'Interval must be positive.'
22
+ }
23
+
24
+ if ($MaxFrames -lt 1 -or $MaxFrames -gt 100) {
25
+ throw 'MaxFrames must be between 1 and 100.'
26
+ }
27
+
28
+ $resolvedInputVideo = [IO.Path]::GetFullPath($InputVideo)
29
+ $run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
30
+ $resolvedRunDirectory = $run.RunDirectory
31
+ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
32
+ $OutputDirectory = Join-Path $resolvedRunDirectory 'frames'
33
+ }
34
+
35
+ $resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
36
+ if (Test-Path -LiteralPath $resolvedOutputDirectory) {
37
+ $outputItem = Get-Item -LiteralPath $resolvedOutputDirectory -Force
38
+ if (-not $outputItem.PSIsContainer) {
39
+ throw "Output directory path is a file: $resolvedOutputDirectory"
40
+ }
41
+
42
+ $existingItems = @(Get-ChildItem -LiteralPath $resolvedOutputDirectory -Force)
43
+ if ($existingItems.Count -gt 0 -and -not $Overwrite) {
44
+ throw "Output directory is not empty; specify -Overwrite to replace frame outputs: $resolvedOutputDirectory"
45
+ }
46
+
47
+ if ($Overwrite) {
48
+ Get-ChildItem -LiteralPath $resolvedOutputDirectory -Filter 'frame-*.png' -File -Force | Remove-Item -Force
49
+ }
50
+ }
51
+ else {
52
+ New-Item -ItemType Directory -Path $resolvedOutputDirectory -ErrorAction Stop | Out-Null
53
+ }
54
+
55
+ $invariantCulture = [Globalization.CultureInfo]::InvariantCulture
56
+ $intervalText = $Interval.ToString('G17', $invariantCulture)
57
+ $ffmpegArguments = [string[]]@(
58
+ '-i', $resolvedInputVideo,
59
+ '-vf', "fps=1/$intervalText",
60
+ '-frames:v', $MaxFrames.ToString($invariantCulture),
61
+ '-start_number', '1'
62
+ )
63
+ if ($Overwrite) {
64
+ $ffmpegArguments += '-y'
65
+ }
66
+ $ffmpegArguments += (Join-Path $resolvedOutputDirectory 'frame-%03d.png')
67
+
68
+ Invoke-Ffmpeg -FfmpegPath (Get-FfmpegPath) -Arguments $ffmpegArguments -Operation "frame extraction from $resolvedInputVideo"
69
+ $resultJson = Write-ResultJson -Result ([pscustomobject]@{
70
+ schema_version = 'agent-verification-lab.result.v1'
71
+ status = 'ok'
72
+ kind = $run.Kind
73
+ run_id = $run.RunId
74
+ run_directory = $resolvedRunDirectory
75
+ artifacts = @([pscustomobject]@{ role = 'frames'; path = $resolvedOutputDirectory; mime = 'application/x-directory' })
76
+ backend = [pscustomobject]@{ name = 'ffmpeg'; version = $null }
77
+ warnings = @()
78
+ })
79
+ Write-VisualResult -RunDirectory $resolvedRunDirectory -OutputPath $resolvedOutputDirectory -Kind 'frames' -ResultJsonPath $resultJson
@@ -0,0 +1,138 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory)]
4
+ [ValidateNotNullOrEmpty()]
5
+ [string]$Input,
6
+ [string]$OutputJson
7
+ )
8
+
9
+ Set-StrictMode -Version Latest
10
+
11
+ $inputPath = $PSBoundParameters['Input']
12
+
13
+ function Get-PropertyOrNull {
14
+ [OutputType([object])]
15
+ param(
16
+ [AllowNull()]
17
+ [object]$Object,
18
+ [Parameter(Mandatory)]
19
+ [string]$Name
20
+ )
21
+
22
+ if ($null -eq $Object) {
23
+ return $null
24
+ }
25
+
26
+ $property = $Object.PSObject.Properties[$Name]
27
+ if ($null -eq $property) {
28
+ return $null
29
+ }
30
+
31
+ return $property.Value
32
+ }
33
+
34
+ function Get-FirstStreamOfType {
35
+ [OutputType([object])]
36
+ param(
37
+ [AllowNull()]
38
+ [object[]]$Streams,
39
+ [Parameter(Mandatory)]
40
+ [string]$CodecType
41
+ )
42
+
43
+ $matches = @($Streams | Where-Object { (Get-PropertyOrNull -Object $_ -Name 'codec_type') -eq $CodecType })
44
+ if ($matches.Count -eq 0) {
45
+ return $null
46
+ }
47
+
48
+ return $matches[0]
49
+ }
50
+
51
+ function New-StreamSummary {
52
+ [OutputType([pscustomobject])]
53
+ param(
54
+ [AllowNull()]
55
+ [object]$Stream
56
+ )
57
+
58
+ return [pscustomobject]@{
59
+ start_time = Get-PropertyOrNull -Object $Stream -Name 'start_time'
60
+ duration = Get-PropertyOrNull -Object $Stream -Name 'duration'
61
+ time_base = Get-PropertyOrNull -Object $Stream -Name 'time_base'
62
+ codec = Get-PropertyOrNull -Object $Stream -Name 'codec_name'
63
+ }
64
+ }
65
+
66
+ if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) {
67
+ throw "Input media file does not exist or is not a file: $inputPath"
68
+ }
69
+
70
+ $resolvedInput = [IO.Path]::GetFullPath($inputPath)
71
+ if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
72
+ $resolvedOutputJson = [IO.Path]::GetFullPath($OutputJson)
73
+ $outputDirectory = Split-Path -Parent $resolvedOutputJson
74
+ if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
75
+ throw "OutputJson directory does not exist: $outputDirectory"
76
+ }
77
+ if (Test-Path -LiteralPath $resolvedOutputJson) {
78
+ throw "OutputJson already exists; refusing to overwrite: $resolvedOutputJson"
79
+ }
80
+ }
81
+
82
+ try {
83
+ $ffprobeCommand = Get-Command ffprobe -ErrorAction Stop
84
+ }
85
+ catch {
86
+ throw 'ffprobe was not found on PATH.'
87
+ }
88
+
89
+ if ($ffprobeCommand.CommandType -ne [System.Management.Automation.CommandTypes]::Application) {
90
+ throw "ffprobe resolved to '$($ffprobeCommand.CommandType)', not an executable application."
91
+ }
92
+
93
+ $ffprobeArguments = @(
94
+ '-v', 'error',
95
+ '-show_entries', 'format=start_time,duration,size:stream=codec_type,codec_name,start_time,duration,time_base,avg_frame_rate,width,height,sample_rate',
96
+ '-of', 'json',
97
+ $resolvedInput
98
+ )
99
+ $ffprobeOutput = & $ffprobeCommand.Source @ffprobeArguments 2>&1
100
+ $ffprobeExitCode = $LASTEXITCODE
101
+ if ($ffprobeExitCode -ne 0) {
102
+ $diagnostics = ($ffprobeOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
103
+ throw "ffprobe inspection failed with exit code $ffprobeExitCode for '$resolvedInput'.$([Environment]::NewLine)$diagnostics"
104
+ }
105
+
106
+ try {
107
+ $probe = (($ffprobeOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine | ConvertFrom-Json -ErrorAction Stop)
108
+ }
109
+ catch {
110
+ throw "ffprobe returned invalid JSON for '$resolvedInput': $($_.Exception.Message)"
111
+ }
112
+
113
+ $videoStream = Get-FirstStreamOfType -Streams @($probe.streams) -CodecType 'video'
114
+ $audioStream = Get-FirstStreamOfType -Streams @($probe.streams) -CodecType 'audio'
115
+ $video = New-StreamSummary -Stream $videoStream
116
+ $video | Add-Member -NotePropertyName frame_rate -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'avg_frame_rate')
117
+ $video | Add-Member -NotePropertyName width -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'width')
118
+ $video | Add-Member -NotePropertyName height -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'height')
119
+ $audio = New-StreamSummary -Stream $audioStream
120
+ $audio | Add-Member -NotePropertyName sample_rate -NotePropertyValue (Get-PropertyOrNull -Object $audioStream -Name 'sample_rate')
121
+
122
+ $result = [pscustomobject]@{
123
+ schema_version = 'agent-verification-lab.media.v1'
124
+ format = [pscustomobject]@{
125
+ start_time = Get-PropertyOrNull -Object $probe.format -Name 'start_time'
126
+ duration = Get-PropertyOrNull -Object $probe.format -Name 'duration'
127
+ size = Get-PropertyOrNull -Object $probe.format -Name 'size'
128
+ }
129
+ video = $video
130
+ audio = $audio
131
+ }
132
+
133
+ $json = $result | ConvertTo-Json -Depth 4
134
+ if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
135
+ Set-Content -LiteralPath $resolvedOutputJson -Value $json -Encoding utf8NoBOM -NoNewline -ErrorAction Stop
136
+ }
137
+
138
+ Write-Output $json