@zerwiz/ymir 0.1.4 → 0.1.7
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.
- package/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/README.md +74 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +360 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +11 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +121 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +90 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +57 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +35 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/install.md +69 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +498 -0
- package/CHANGELOG.md +1913 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +114 -60
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +97 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +108 -0
- package/RULES/07-config.md +101 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/09-electron.md +58 -0
- package/RULES/README.md +22 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +133 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +256 -0
- package/bin/apodex-smoke-test.sh +105 -0
- package/bin/bifrost-bridge.sh +131 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +112 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +51 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-assemble.sh +154 -0
- package/bin/changelog-guard.sh +197 -0
- package/bin/crash-sense.sh +173 -0
- package/bin/design-check.sh +80 -0
- package/bin/design-icon.sh +153 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +225 -0
- package/bin/eindri-acclaim.sh +47 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seat.sh +83 -0
- package/bin/eindri-seen.sh +54 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +98 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +158 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/feature-inventory.sh +73 -0
- package/bin/fleet-apply.sh +81 -0
- package/bin/github-deploy.sh +94 -0
- package/bin/gjallarhorn-expose.sh +136 -0
- package/bin/gjallarhorn-notify.sh +71 -0
- package/bin/gjallarhorn-purge.sh +67 -0
- package/bin/gjallarhorn-tunnel.sh +85 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +130 -0
- package/bin/hall-snapshot.sh +111 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +452 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hlidskjalf-agents.sh +172 -0
- package/bin/hlidskjalf-usage.sh +196 -0
- package/bin/hoard-guard.sh +135 -0
- package/bin/hoard-lib.sh +105 -0
- package/bin/hodd.sh +102 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +121 -0
- package/bin/local-model-lock.sh +70 -0
- package/bin/mimir-bridge.py +259 -0
- package/bin/mimir-bridge.sh +100 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir-reflect.sh +27 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +72 -0
- package/bin/mjollnir.sh +104 -0
- package/bin/model-hardware.sh +96 -0
- package/bin/model-resolve.sh +160 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-hall-snapshot.sh +28 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-nsr-compliance.sh +54 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/npm-publish.sh +136 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +161 -0
- package/bin/omarchy-plugins.sh +187 -0
- package/bin/omarchy-sense.sh +162 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-model-agnostic.sh +71 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +58 -0
- package/bin/project-git.sh +69 -0
- package/bin/public-guard.sh +71 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +104 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +94 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +89 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-spawn-pretool-check.sh +58 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +203 -0
- package/bin/telegram-bot.sh +82 -0
- package/bin/toolchain.sh +74 -0
- package/bin/ui-truth-check.sh +75 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +298 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +60 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +163 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +806 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-plan.sh +356 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +110 -0
- package/bin/ymir-setup-auth.sh +119 -0
- package/bin/ymir-validate.sh +193 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1060 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +54 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +269 -0
- package/scripts/lower.sh +34 -0
- package/scripts/raise.sh +42 -0
- package/scripts/start.sh +217 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fm-voice-client.py - the captain's laptop end of the spoken interface.
|
|
3
|
+
|
|
4
|
+
Captures audio on the laptop, streams it over the SSH connection the captain
|
|
5
|
+
already has to the desktop, plays back the spoken reply, and reports how long
|
|
6
|
+
the round trip took. The desktop holds the Bedrock session and the AWS
|
|
7
|
+
credentials; this client needs neither. It needs Python and a microphone.
|
|
8
|
+
|
|
9
|
+
WHAT IS VERIFIED AND WHAT IS NOT. Read this before trusting a number from it.
|
|
10
|
+
|
|
11
|
+
Verified on the desktop: the frame protocol, the SSH transport, the relay
|
|
12
|
+
handshake, turn sequencing, the reply audio arriving intact, and the timing
|
|
13
|
+
arithmetic. All of that was exercised with --in-file and --out-file, which
|
|
14
|
+
replace the microphone and the speaker with files and leave everything else
|
|
15
|
+
alone.
|
|
16
|
+
|
|
17
|
+
NOT verified, and cannot be from here: the audio DEVICES. The desktop this was
|
|
18
|
+
written on has neither a microphone nor a speaker, and no worker can reach the
|
|
19
|
+
captain's laptop. The sounddevice calls below are written from its documented
|
|
20
|
+
interface and have never been run against a real device. Treat the first live
|
|
21
|
+
run as the test.
|
|
22
|
+
|
|
23
|
+
Verified, and worth telling apart from the devices: the speaker's own byte
|
|
24
|
+
ACCOUNTING, which is the arithmetic deciding which turn a chunk of reply audio
|
|
25
|
+
is credited to and which turn's first-audio clock it stamps. That is plain
|
|
26
|
+
logic rather than device work, so it is exercised against a stub stream with
|
|
27
|
+
the callback driven by hand. Nothing in that says how a real output device
|
|
28
|
+
behaves.
|
|
29
|
+
|
|
30
|
+
TWO KINDS OF LISTENING, one of them built. --listen push-to-talk is the default
|
|
31
|
+
and the only mode that runs: the captain says when they are talking, the model is
|
|
32
|
+
only paid for that audio, and nothing is streamed while they are thinking.
|
|
33
|
+
|
|
34
|
+
--listen open-mic is accepted as a setting and REFUSES at startup. Streaming
|
|
35
|
+
continuously needs something to decide when the captain stopped speaking, and
|
|
36
|
+
this client has no end-of-speech detection: it would open a turn, stream audio
|
|
37
|
+
forever and never mark a boundary, so the relay would keep appending to a session
|
|
38
|
+
that had already answered. That detection belongs with session continuity across
|
|
39
|
+
turns, which is step three of the design. The setting stays here so that turning
|
|
40
|
+
it on later is a small change rather than a new flag, and refusing is honest
|
|
41
|
+
where half a mode would not be.
|
|
42
|
+
|
|
43
|
+
Copy this file and fm_voice_frame.py to the laptop; they are the only two files
|
|
44
|
+
it needs and both are standard library only, apart from sounddevice for the
|
|
45
|
+
audio devices.
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
fm-voice-client.py --host <sshhost> [options]
|
|
49
|
+
fm-voice-client.py --local [options] (relay as a child, no SSH)
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--host <name> SSH destination of the desktop holding the relay.
|
|
53
|
+
--local run the relay as a local child process instead. This is
|
|
54
|
+
how the relay path is measured without a laptop.
|
|
55
|
+
--relay <path> path to fm-voice-relay.py on the desktop, or set
|
|
56
|
+
$FM_VOICE_RELAY. Required: this file carries no default,
|
|
57
|
+
because one operator's home directory is not a path to
|
|
58
|
+
hand anybody else.
|
|
59
|
+
--relay-python <path> interpreter that has aws-sdk-bedrock-runtime installed.
|
|
60
|
+
default $FM_VOICE_PYTHON or python3
|
|
61
|
+
--relay-arg <arg> extra argument for the relay, repeatable. Write it
|
|
62
|
+
joined with an equals sign, --relay-arg=--scope
|
|
63
|
+
--relay-arg=counts, or the leading dashes are read as
|
|
64
|
+
options of this client instead.
|
|
65
|
+
--listen <mode> push-to-talk, the default and the only mode that runs.
|
|
66
|
+
open-mic is accepted and refuses; see above.
|
|
67
|
+
--runs <n> turns to take in one session. default 1
|
|
68
|
+
--talk-seconds <sec> capture for this long instead of waiting on a keypress.
|
|
69
|
+
--in-file <file.pcm> raw 16 kHz mono 16-bit input instead of the microphone.
|
|
70
|
+
--out-file <file.pcm> write reply audio here instead of playing it.
|
|
71
|
+
--input-device <id> sounddevice input device.
|
|
72
|
+
--output-device <id> sounddevice output device.
|
|
73
|
+
--timeout <sec> how long to wait for a reply. default 30
|
|
74
|
+
--no-wait-for-reply open the next turn without waiting for the previous
|
|
75
|
+
answer to finish. The model treats that as being
|
|
76
|
+
interrupted and stops instead of answering, so this
|
|
77
|
+
exists to reproduce the trap, not to use.
|
|
78
|
+
--gap-seconds <sec> quiet beat after an answer finishes. default 0.5
|
|
79
|
+
--verbose log the session to stderr.
|
|
80
|
+
|
|
81
|
+
One JSON record per turn goes to stdout; everything human goes to stderr, so
|
|
82
|
+
`fm-voice-client.py --host desktop --runs 5 > runs.jsonl` gives measurements and
|
|
83
|
+
a readable session at the same time.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
import argparse
|
|
87
|
+
import json
|
|
88
|
+
import os
|
|
89
|
+
import queue
|
|
90
|
+
import subprocess
|
|
91
|
+
import sys
|
|
92
|
+
import threading
|
|
93
|
+
import time
|
|
94
|
+
import traceback
|
|
95
|
+
|
|
96
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
97
|
+
|
|
98
|
+
import fm_voice_frame as frame # noqa: E402
|
|
99
|
+
|
|
100
|
+
IN_RATE = 16000
|
|
101
|
+
OUT_RATE = 24000
|
|
102
|
+
# 100 ms at each rate. The uplink chunk matches what the relay and the earlier
|
|
103
|
+
# prototype work measured with; changing it changes the numbers.
|
|
104
|
+
CHUNK = 3200
|
|
105
|
+
OUT_BLOCK = 2400
|
|
106
|
+
|
|
107
|
+
PUSH_TO_TALK = "push-to-talk"
|
|
108
|
+
OPEN_MIC = "open-mic"
|
|
109
|
+
LISTEN_MODES = (PUSH_TO_TALK, OPEN_MIC)
|
|
110
|
+
|
|
111
|
+
# Anything the relay's login shell prints on stdout ahead of the first frame is
|
|
112
|
+
# discarded, up to this much. Past it, the stream is not a relay.
|
|
113
|
+
MAX_PREAMBLE = 8192
|
|
114
|
+
|
|
115
|
+
# The two ends of a turn, queued rather than written, for the reason _sender
|
|
116
|
+
# gives: everything the uplink carries has to stay in the order it happened in.
|
|
117
|
+
START = object()
|
|
118
|
+
END = object()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DeviceError(Exception):
|
|
122
|
+
"""A microphone or speaker could not be opened, said in one line."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def log(enabled, message):
|
|
126
|
+
if enabled:
|
|
127
|
+
sys.stderr.write("client: {}\n".format(message))
|
|
128
|
+
sys.stderr.flush()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def say(message):
|
|
132
|
+
sys.stderr.write("{}\n".format(message))
|
|
133
|
+
sys.stderr.flush()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------- transport
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sync_magic(stream, verbose=False):
|
|
140
|
+
"""Discard anything ahead of the relay's magic preamble.
|
|
141
|
+
|
|
142
|
+
`ssh host command` runs the command through the captain's login shell, so a
|
|
143
|
+
shell startup file that prints a banner lands in front of the first frame.
|
|
144
|
+
Skipping to the preamble turns that from a baffling protocol error into a
|
|
145
|
+
warning naming the offending text.
|
|
146
|
+
"""
|
|
147
|
+
seen = bytearray()
|
|
148
|
+
while True:
|
|
149
|
+
byte = stream.read(1)
|
|
150
|
+
if not byte:
|
|
151
|
+
raise frame.FrameError(
|
|
152
|
+
"the relay closed the connection before it said hello; run the "
|
|
153
|
+
"relay command by hand over SSH to see its error")
|
|
154
|
+
seen += byte
|
|
155
|
+
if seen.endswith(frame.MAGIC):
|
|
156
|
+
junk = bytes(seen[: -len(frame.MAGIC)])
|
|
157
|
+
if junk:
|
|
158
|
+
say("client: discarded {} bytes your login shell printed before "
|
|
159
|
+
"the relay started: {!r}".format(len(junk), junk[:200]))
|
|
160
|
+
log(verbose, "relay handshake found")
|
|
161
|
+
return
|
|
162
|
+
if len(seen) > MAX_PREAMBLE:
|
|
163
|
+
raise frame.FrameError(
|
|
164
|
+
"no relay handshake in the first {} bytes; the command on the "
|
|
165
|
+
"far end is not fm-voice-relay.py --serve".format(MAX_PREAMBLE))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def relay_command(options):
|
|
169
|
+
"""Return the argv that starts the relay, locally or over SSH."""
|
|
170
|
+
remote = [options.relay_python, options.relay, "--serve"]
|
|
171
|
+
remote += list(options.relay_arg or [])
|
|
172
|
+
if options.verbose:
|
|
173
|
+
remote.append("--verbose")
|
|
174
|
+
if options.local:
|
|
175
|
+
return remote
|
|
176
|
+
# -T because a pty would rewrite bytes in the audio stream, which is the
|
|
177
|
+
# single most confusing way this could fail.
|
|
178
|
+
return ["ssh", "-T", options.host] + remote
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Uplink:
|
|
182
|
+
"""Serialise every frame the client sends, from whichever thread sends it."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, stream):
|
|
185
|
+
self._writer = frame.Writer(stream)
|
|
186
|
+
self._lock = threading.Lock()
|
|
187
|
+
|
|
188
|
+
def send(self, kind, payload=b""):
|
|
189
|
+
with self._lock:
|
|
190
|
+
self._writer.send(kind, payload)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------- playback
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class FilePlayback:
|
|
197
|
+
"""Write reply audio to a file. This is the path that can be verified here.
|
|
198
|
+
|
|
199
|
+
Every chunk carries the turn it belongs to, and turn_reset names the turn
|
|
200
|
+
being measured. A chunk from a turn that has already been recorded is still
|
|
201
|
+
written, because it is the tail of an answer the captain is still listening
|
|
202
|
+
to, but it stamps no clock and is counted toward nobody: attributed to the
|
|
203
|
+
turn that happens to be open, it would hand that turn a first-audio figure
|
|
204
|
+
measured from somebody else's reply and report it answered when it was not.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, path):
|
|
208
|
+
self._handle = open(path, "wb")
|
|
209
|
+
# Two locks, and which one covers what is the point of them. _lock is the
|
|
210
|
+
# per-turn accounting, and turn_reset takes it while the client holds its
|
|
211
|
+
# own turn lock, so nothing slow may ever be done under it. _handle_lock
|
|
212
|
+
# covers the file itself, so a write and a close cannot overlap. The
|
|
213
|
+
# ordering is always _handle_lock then _lock and never the reverse.
|
|
214
|
+
#
|
|
215
|
+
# What close() needing _handle_lock costs: the exit is now only as bounded
|
|
216
|
+
# as one write to --out-file, so on a hung or full filesystem the five
|
|
217
|
+
# second downlink join in Client.close no longer bounds it. The wedged relay
|
|
218
|
+
# that join was written for is unaffected, being another process while this
|
|
219
|
+
# write is local. That cost belongs to the filed teardown-ordering work,
|
|
220
|
+
# whose other half is the same five second join being shorter than the ten
|
|
221
|
+
# seconds the relay may spend draining its own reply stream, which is why
|
|
222
|
+
# audio can arrive after the output is released at all.
|
|
223
|
+
self._handle_lock = threading.Lock()
|
|
224
|
+
self._lock = threading.Lock()
|
|
225
|
+
self.first_played = None
|
|
226
|
+
self.device_latency = None
|
|
227
|
+
self.turn_bytes = 0
|
|
228
|
+
# Chunks dropped because they arrived after the file was released. Read by
|
|
229
|
+
# --verbose only; see write() for why it is not an outcome input.
|
|
230
|
+
self.discarded = 0
|
|
231
|
+
self._turn = None
|
|
232
|
+
self._closed = False
|
|
233
|
+
|
|
234
|
+
def write(self, pcm, turn):
|
|
235
|
+
# A chunk arriving after close is DISCARDED rather than raising. close()
|
|
236
|
+
# joins the downlink at five seconds while the relay teardown it waits on
|
|
237
|
+
# can take up to ten, so reply audio still in flight when the file is
|
|
238
|
+
# released is an expected and benign race, and erroring on it reported this
|
|
239
|
+
# end's own teardown as a fault through the frame-handling guard, on a
|
|
240
|
+
# session that worked. Discard is the honest semantic for it, and after
|
|
241
|
+
# this any fault line printed during teardown is a real one.
|
|
242
|
+
#
|
|
243
|
+
# Counted, because a write after close OUTSIDE teardown is a logic bug and
|
|
244
|
+
# a silent no-op would hide it. Counted and nothing more: discarded bytes
|
|
245
|
+
# stamp no clock, are credited to no turn, and so reach neither answered,
|
|
246
|
+
# first_audio_s nor the exit code, which are decided from turn_bytes.
|
|
247
|
+
#
|
|
248
|
+
# The turn comparison, the stamp and the count are one decision and are
|
|
249
|
+
# made together under _lock. The file write is not: it blocks, and holding
|
|
250
|
+
# the lock turn_reset needs across it would stall the whole client behind
|
|
251
|
+
# the filesystem. One writer keeps the file in order without that.
|
|
252
|
+
with self._handle_lock:
|
|
253
|
+
if self._closed:
|
|
254
|
+
with self._lock:
|
|
255
|
+
self.discarded += 1
|
|
256
|
+
return
|
|
257
|
+
with self._lock:
|
|
258
|
+
mine = turn == self._turn
|
|
259
|
+
if mine and self.first_played is None:
|
|
260
|
+
self.first_played = time.monotonic()
|
|
261
|
+
if mine:
|
|
262
|
+
self.turn_bytes += len(pcm)
|
|
263
|
+
self._handle.write(pcm)
|
|
264
|
+
|
|
265
|
+
def turn_reset(self, turn):
|
|
266
|
+
with self._lock:
|
|
267
|
+
self._turn = turn
|
|
268
|
+
self.first_played = None
|
|
269
|
+
self.turn_bytes = 0
|
|
270
|
+
|
|
271
|
+
def drain(self, timeout=5):
|
|
272
|
+
del timeout
|
|
273
|
+
|
|
274
|
+
def close(self):
|
|
275
|
+
with self._handle_lock:
|
|
276
|
+
self._closed = True
|
|
277
|
+
self._handle.close()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class SpeakerPlayback:
|
|
281
|
+
"""Play reply audio through the laptop speaker.
|
|
282
|
+
|
|
283
|
+
The DEVICE is UNVERIFIED: written from the sounddevice interface and never run
|
|
284
|
+
against a real one, because the machine this was built on has no speaker, so
|
|
285
|
+
the first live run is its test. The byte ACCOUNTING below is covered, against
|
|
286
|
+
a stub stream with the callback driven by hand, and covering it says nothing
|
|
287
|
+
about how a real device behaves.
|
|
288
|
+
|
|
289
|
+
The timestamp is taken when the audio is handed to the device callback, which
|
|
290
|
+
is the last moment this process can see. The device's own output buffer sits
|
|
291
|
+
after that, so its reported latency is included in the turn record rather
|
|
292
|
+
than pretended away.
|
|
293
|
+
|
|
294
|
+
That timestamp is why the turn a chunk belongs to has to travel with the
|
|
295
|
+
chunk rather than being checked before the write: the moment that matters
|
|
296
|
+
happens in the callback, later than the frame arriving, and the gap between
|
|
297
|
+
the two is the honest content of the figure. So the buffer remembers how many
|
|
298
|
+
of its leading bytes belong to turns already recorded, and the first audio of
|
|
299
|
+
the turn being measured is the first byte past them. Ordering makes that a
|
|
300
|
+
count rather than a per-chunk tag: the downlink hands chunks over in arrival
|
|
301
|
+
order on one thread and a turn number never goes backwards, so a chunk from an
|
|
302
|
+
earlier turn can never queue behind one from a later turn.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def __init__(self, device=None):
|
|
306
|
+
import sounddevice # noqa: PLC0415
|
|
307
|
+
self._buffer = bytearray()
|
|
308
|
+
self._lock = threading.Lock()
|
|
309
|
+
self.first_played = None
|
|
310
|
+
self.turn_bytes = 0
|
|
311
|
+
# The same diagnostic the file path keeps, for the same reason. A counter
|
|
312
|
+
# that can only ever read zero is indistinguishable from one that measured
|
|
313
|
+
# zero, and this is the path the captain will actually use, so the write
|
|
314
|
+
# after close that the counter exists to catch has to be visible here too.
|
|
315
|
+
self.discarded = 0
|
|
316
|
+
self._turn = None
|
|
317
|
+
self._earlier = 0
|
|
318
|
+
self._closed = False
|
|
319
|
+
self._stream = sounddevice.RawOutputStream(
|
|
320
|
+
samplerate=OUT_RATE, channels=1, dtype="int16",
|
|
321
|
+
blocksize=OUT_BLOCK, device=device, latency="low",
|
|
322
|
+
callback=self._callback)
|
|
323
|
+
self._stream.start()
|
|
324
|
+
self.device_latency = getattr(self._stream, "latency", None)
|
|
325
|
+
|
|
326
|
+
def _callback(self, outdata, frames_wanted, time_info, status):
|
|
327
|
+
del time_info, status
|
|
328
|
+
want = frames_wanted * 2
|
|
329
|
+
with self._lock:
|
|
330
|
+
take = min(want, len(self._buffer))
|
|
331
|
+
chunk = bytes(self._buffer[:take])
|
|
332
|
+
del self._buffer[:take]
|
|
333
|
+
spent = min(self._earlier, take)
|
|
334
|
+
self._earlier -= spent
|
|
335
|
+
if take > spent and self.first_played is None:
|
|
336
|
+
self.first_played = time.monotonic()
|
|
337
|
+
outdata[:take] = chunk
|
|
338
|
+
if take < want:
|
|
339
|
+
outdata[take:want] = b"\x00" * (want - take)
|
|
340
|
+
|
|
341
|
+
def write(self, pcm, turn):
|
|
342
|
+
with self._lock:
|
|
343
|
+
# close() stops the stream, and after that no callback drains the
|
|
344
|
+
# buffer, so a chunk arriving here was never going to be heard however
|
|
345
|
+
# it is stored. Discarded and counted rather than queued and credited
|
|
346
|
+
# to the turn, which is what the file path does: queued, it is a
|
|
347
|
+
# measurement the captain never heard, and silent, the write after
|
|
348
|
+
# close outside teardown that this counts for would be invisible on the
|
|
349
|
+
# one path they use. Counted and nothing more, so it stamps no clock
|
|
350
|
+
# and reaches neither answered, first_audio_s nor the exit code.
|
|
351
|
+
if self._closed:
|
|
352
|
+
self.discarded += 1
|
|
353
|
+
return
|
|
354
|
+
if turn == self._turn:
|
|
355
|
+
self.turn_bytes += len(pcm)
|
|
356
|
+
else:
|
|
357
|
+
self._earlier += len(pcm)
|
|
358
|
+
self._buffer += pcm
|
|
359
|
+
|
|
360
|
+
def turn_reset(self, turn):
|
|
361
|
+
with self._lock:
|
|
362
|
+
self._turn = turn
|
|
363
|
+
self.first_played = None
|
|
364
|
+
self.turn_bytes = 0
|
|
365
|
+
# Whatever is still queued was spoken for an earlier turn. Counted as
|
|
366
|
+
# this turn's, the previous answer's undrained tail would stamp this
|
|
367
|
+
# turn's first audio the instant the device next asked for a block.
|
|
368
|
+
self._earlier = len(self._buffer)
|
|
369
|
+
|
|
370
|
+
def drain(self, timeout=30):
|
|
371
|
+
"""Wait for the buffered reply to finish, so the process does not cut it off."""
|
|
372
|
+
deadline = time.monotonic() + timeout
|
|
373
|
+
while time.monotonic() < deadline:
|
|
374
|
+
with self._lock:
|
|
375
|
+
if not self._buffer:
|
|
376
|
+
break
|
|
377
|
+
time.sleep(0.05)
|
|
378
|
+
time.sleep(0.2)
|
|
379
|
+
|
|
380
|
+
def close(self):
|
|
381
|
+
# Marked before the stream is stopped and not while the lock is held: the
|
|
382
|
+
# device callback takes this lock, and stop() waits for a callback already
|
|
383
|
+
# running, so holding it across the stop is a deadlock. Marking first
|
|
384
|
+
# instead leaves no instant where the stream is gone and a write still
|
|
385
|
+
# queues for it.
|
|
386
|
+
with self._lock:
|
|
387
|
+
self._closed = True
|
|
388
|
+
try:
|
|
389
|
+
self._stream.stop()
|
|
390
|
+
self._stream.close()
|
|
391
|
+
except Exception: # noqa: BLE001
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ----------------------------------------------------------------------- capture
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class FileCapture:
|
|
399
|
+
"""Stream a PCM file as if it were the microphone, paced at real time.
|
|
400
|
+
|
|
401
|
+
Paced deliberately: a file pushed as fast as the socket accepts it measures
|
|
402
|
+
the socket rather than the conversation.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(self, path):
|
|
406
|
+
with open(path, "rb") as handle:
|
|
407
|
+
self._pcm = handle.read()
|
|
408
|
+
self.seconds = round(len(self._pcm) / float(IN_RATE * 2), 3)
|
|
409
|
+
self.device_latency = None
|
|
410
|
+
self._q = None
|
|
411
|
+
self._talking = None
|
|
412
|
+
self._done = threading.Event()
|
|
413
|
+
|
|
414
|
+
def start(self, out_q, talking):
|
|
415
|
+
self._q = out_q
|
|
416
|
+
self._talking = talking
|
|
417
|
+
|
|
418
|
+
def begin_turn(self):
|
|
419
|
+
"""Start feeding the file. One pass per turn, from the top each time."""
|
|
420
|
+
self._done.clear()
|
|
421
|
+
|
|
422
|
+
def run():
|
|
423
|
+
for at in range(0, len(self._pcm), CHUNK):
|
|
424
|
+
if not self._talking.is_set():
|
|
425
|
+
return
|
|
426
|
+
self._q.put(self._pcm[at:at + CHUNK])
|
|
427
|
+
time.sleep(CHUNK / float(IN_RATE * 2))
|
|
428
|
+
self._done.set()
|
|
429
|
+
|
|
430
|
+
threading.Thread(target=run, daemon=True).start()
|
|
431
|
+
|
|
432
|
+
def wait_exhausted(self, timeout):
|
|
433
|
+
return self._done.wait(timeout)
|
|
434
|
+
|
|
435
|
+
def close(self):
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class MicCapture:
|
|
440
|
+
"""Capture from the laptop microphone.
|
|
441
|
+
|
|
442
|
+
UNVERIFIED: written from the sounddevice interface and never run against a
|
|
443
|
+
real device. The stream stays open for the whole session and the gate decides
|
|
444
|
+
what is sent, so push to talk costs no device setup per turn and the model is
|
|
445
|
+
only paid for audio while the gate is open.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
def __init__(self, device=None):
|
|
449
|
+
import sounddevice # noqa: PLC0415
|
|
450
|
+
self.seconds = None
|
|
451
|
+
self._q = None
|
|
452
|
+
self._talking = None
|
|
453
|
+
self._stream = sounddevice.RawInputStream(
|
|
454
|
+
samplerate=IN_RATE, channels=1, dtype="int16",
|
|
455
|
+
blocksize=CHUNK // 2, device=device, latency="low",
|
|
456
|
+
callback=self._callback)
|
|
457
|
+
self._stream.start()
|
|
458
|
+
self.device_latency = getattr(self._stream, "latency", None)
|
|
459
|
+
|
|
460
|
+
def _callback(self, indata, frames_read, time_info, status):
|
|
461
|
+
del frames_read, time_info, status
|
|
462
|
+
if self._talking is not None and self._talking.is_set():
|
|
463
|
+
self._q.put(bytes(indata))
|
|
464
|
+
|
|
465
|
+
def start(self, out_q, talking):
|
|
466
|
+
self._q = out_q
|
|
467
|
+
self._talking = talking
|
|
468
|
+
|
|
469
|
+
def begin_turn(self):
|
|
470
|
+
"""Nothing to do: the device stream is already open and the gate decides."""
|
|
471
|
+
|
|
472
|
+
def wait_exhausted(self, timeout):
|
|
473
|
+
del timeout
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
def close(self):
|
|
477
|
+
try:
|
|
478
|
+
self._stream.stop()
|
|
479
|
+
self._stream.close()
|
|
480
|
+
except Exception: # noqa: BLE001
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------- audio setup
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def open_file_end(flag, path, build):
|
|
488
|
+
"""Open a file-backed end of the audio, naming the path and the flag for it.
|
|
489
|
+
|
|
490
|
+
The file ends are the ones this host can run, and they are what every figure
|
|
491
|
+
in docs/voice-relay.md was measured with, so their refusal is the one most
|
|
492
|
+
likely to be read. It stays an OSError, which main prints as it stands, and it
|
|
493
|
+
names the path and the flag that chose it. Reporting a mistyped path as a
|
|
494
|
+
device failure would send the reader to the device flags instead of to the
|
|
495
|
+
path.
|
|
496
|
+
"""
|
|
497
|
+
try:
|
|
498
|
+
return build()
|
|
499
|
+
except OSError as exc:
|
|
500
|
+
raise OSError("could not open {}, given as {}: {}".format(
|
|
501
|
+
path, flag, exc))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def open_device_end(flag, build):
|
|
505
|
+
"""Open a device-backed end of the audio, or refuse in one line with a next step.
|
|
506
|
+
|
|
507
|
+
sounddevice raises its own error types and is an optional import, so neither
|
|
508
|
+
shape reaches main as an OSError on its own and a traceback is what the
|
|
509
|
+
captain would otherwise get. Whether this refusal ever fires, and what a real
|
|
510
|
+
device says when it does, is unverified for the reason the module docstring
|
|
511
|
+
gives.
|
|
512
|
+
"""
|
|
513
|
+
try:
|
|
514
|
+
return build()
|
|
515
|
+
except Exception as exc: # noqa: BLE001
|
|
516
|
+
raise DeviceError(
|
|
517
|
+
"could not open the audio device ({}: {}). Name another one with {}, "
|
|
518
|
+
"or run without a device using --in-file and --out-file".format(
|
|
519
|
+
type(exc).__name__, exc, flag))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------------------ client
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class Client:
|
|
526
|
+
"""One relay connection and the turns taken over it."""
|
|
527
|
+
|
|
528
|
+
def __init__(self, options):
|
|
529
|
+
self.options = options
|
|
530
|
+
self.verbose = options.verbose
|
|
531
|
+
self.proc = None
|
|
532
|
+
self.reader = None
|
|
533
|
+
self.uplink = None
|
|
534
|
+
self.playback = None
|
|
535
|
+
self.capture = None
|
|
536
|
+
self.down_thread = None
|
|
537
|
+
self.up_q = queue.Queue()
|
|
538
|
+
self.talking = threading.Event()
|
|
539
|
+
self.ready = threading.Event()
|
|
540
|
+
self.reply_done = threading.Event()
|
|
541
|
+
self.closed = threading.Event()
|
|
542
|
+
# Set the moment this end asks the relay to stop. It is the only thing
|
|
543
|
+
# that tells an expected goodbye from the relay stopping on its own,
|
|
544
|
+
# because the frame is the same one either way, and reading a clean end as
|
|
545
|
+
# a fault would train the captain to ignore the line that means it.
|
|
546
|
+
self.quitting = threading.Event()
|
|
547
|
+
self.ready_notice = {}
|
|
548
|
+
self.turn = {}
|
|
549
|
+
# Which turn self.turn is. A frame is read on one thread and applied on
|
|
550
|
+
# another, so a reply that arrives late, or a notice whose handling is
|
|
551
|
+
# descheduled, can be applied after the turn it belongs to has already
|
|
552
|
+
# been recorded and the next one opened. Without an identity to compare,
|
|
553
|
+
# that reply lands on the wrong turn: it names a fault that turn never
|
|
554
|
+
# had, releases it before its own answer, and stamps its first and last
|
|
555
|
+
# audio, which are the figures this whole tool exists to report. The
|
|
556
|
+
# downlink takes a copy of this when a frame arrives and applies nothing
|
|
557
|
+
# once it no longer matches.
|
|
558
|
+
self.turn_id = 0
|
|
559
|
+
# What run() tells the captain when no further turn can be taken. Every
|
|
560
|
+
# path that makes the connection unusable names itself here, so the line
|
|
561
|
+
# about the runs that were lost restates the cause that was recorded
|
|
562
|
+
# rather than asserting one; a line naming the wrong cause sends them
|
|
563
|
+
# looking where the fault is not. The default only covers a closure with
|
|
564
|
+
# no path at all behind it, which nothing here can currently produce.
|
|
565
|
+
self.closed_because = "the connection closed"
|
|
566
|
+
self.lock = threading.Lock()
|
|
567
|
+
|
|
568
|
+
# ------------------------------------------------------------------ lifecycle
|
|
569
|
+
|
|
570
|
+
def open(self):
|
|
571
|
+
"""Start the relay, the audio devices and the two frame threads.
|
|
572
|
+
|
|
573
|
+
A startup that refuses part way through releases whatever it already
|
|
574
|
+
started, including on the SystemExit _wait_ready raises: a started
|
|
575
|
+
PortAudio stream left open at interpreter shutdown is a known hang on
|
|
576
|
+
macOS, which is the laptop this runs on. Whether it releases them
|
|
577
|
+
correctly against a real device is not something this host can show, for
|
|
578
|
+
the reason the module docstring gives.
|
|
579
|
+
"""
|
|
580
|
+
try:
|
|
581
|
+
self._start()
|
|
582
|
+
except BaseException:
|
|
583
|
+
self.close()
|
|
584
|
+
raise
|
|
585
|
+
|
|
586
|
+
def _start(self):
|
|
587
|
+
argv = relay_command(self.options)
|
|
588
|
+
log(self.verbose, "starting relay: {}".format(" ".join(argv)))
|
|
589
|
+
self.proc = subprocess.Popen(
|
|
590
|
+
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
591
|
+
sync_magic(self.proc.stdout, self.verbose)
|
|
592
|
+
self.reader = frame.Reader(self.proc.stdout)
|
|
593
|
+
self.uplink = Uplink(self.proc.stdin)
|
|
594
|
+
|
|
595
|
+
if self.options.out_file:
|
|
596
|
+
self.playback = open_file_end(
|
|
597
|
+
"--out-file", self.options.out_file,
|
|
598
|
+
lambda: FilePlayback(self.options.out_file))
|
|
599
|
+
else:
|
|
600
|
+
self.playback = open_device_end(
|
|
601
|
+
"--output-device",
|
|
602
|
+
lambda: SpeakerPlayback(self.options.output_device))
|
|
603
|
+
|
|
604
|
+
if self.options.in_file:
|
|
605
|
+
self.capture = open_file_end(
|
|
606
|
+
"--in-file", self.options.in_file,
|
|
607
|
+
lambda: FileCapture(self.options.in_file))
|
|
608
|
+
else:
|
|
609
|
+
self.capture = open_device_end(
|
|
610
|
+
"--input-device",
|
|
611
|
+
lambda: MicCapture(self.options.input_device))
|
|
612
|
+
self.capture.start(self.up_q, self.talking)
|
|
613
|
+
|
|
614
|
+
self.down_thread = threading.Thread(target=self._downlink, daemon=True)
|
|
615
|
+
self.down_thread.start()
|
|
616
|
+
threading.Thread(target=self._sender, daemon=True).start()
|
|
617
|
+
|
|
618
|
+
self._wait_ready()
|
|
619
|
+
notice = self.ready_notice
|
|
620
|
+
say("client: relay ready, {} in {}, read scope {}, connected in {}s".format(
|
|
621
|
+
notice.get("model", "?"), notice.get("region", "?"),
|
|
622
|
+
notice.get("read_scope", "?"), notice.get("connect_seconds", "?")))
|
|
623
|
+
|
|
624
|
+
def _wait_ready(self):
|
|
625
|
+
"""Wait for the relay's ready notice, or for the connection to close first.
|
|
626
|
+
|
|
627
|
+
A relay that dies after the handshake is the likely first-run failure:
|
|
628
|
+
the Bedrock SDK is imported inside the model session, so a forgotten
|
|
629
|
+
--relay-python exits the relay after the handshake and before ready. Its
|
|
630
|
+
own one-line error is already on the captain's terminal, because stderr is
|
|
631
|
+
inherited rather than piped, so waiting out the full timeout after that
|
|
632
|
+
just leaves them watching nothing.
|
|
633
|
+
"""
|
|
634
|
+
deadline = time.monotonic() + self.options.timeout
|
|
635
|
+
while not self.ready.is_set():
|
|
636
|
+
if self.closed.is_set():
|
|
637
|
+
raise SystemExit(
|
|
638
|
+
"fm-voice-client: the relay closed the connection before it "
|
|
639
|
+
"was ready; run the relay command by hand over SSH to see "
|
|
640
|
+
"its error")
|
|
641
|
+
if time.monotonic() >= deadline:
|
|
642
|
+
raise SystemExit(
|
|
643
|
+
"fm-voice-client: the relay never reported ready; run it by "
|
|
644
|
+
"hand over SSH to see why")
|
|
645
|
+
self.ready.wait(0.2)
|
|
646
|
+
|
|
647
|
+
def _quietly(self, what, action):
|
|
648
|
+
"""Run one cleanup step without letting it mask why we are cleaning up."""
|
|
649
|
+
try:
|
|
650
|
+
action()
|
|
651
|
+
except Exception as exc: # noqa: BLE001
|
|
652
|
+
log(self.verbose, "{} did not close cleanly: {}: {}".format(
|
|
653
|
+
what, type(exc).__name__, exc))
|
|
654
|
+
|
|
655
|
+
def close(self):
|
|
656
|
+
# Every step is guarded and every field is checked, because close() also
|
|
657
|
+
# runs from a startup that refused part way through, where the later
|
|
658
|
+
# fields are still None and the original refusal is the message worth
|
|
659
|
+
# keeping.
|
|
660
|
+
if self.uplink is not None:
|
|
661
|
+
# Before the frame, so the goodbye that answers it is read as the
|
|
662
|
+
# answer to a question this end asked rather than as the relay
|
|
663
|
+
# stopping on its own.
|
|
664
|
+
self.quitting.set()
|
|
665
|
+
self._quietly("the uplink", lambda: self.uplink.send(frame.QUIT))
|
|
666
|
+
# Before the devices are released, so the reply the goodbye above answers
|
|
667
|
+
# has somewhere to land, and bounded so a wedged relay cannot hold the
|
|
668
|
+
# exit. The bound is shorter than the relay's own teardown, so audio can
|
|
669
|
+
# still arrive after the output is released; the playback discards that
|
|
670
|
+
# rather than raising, which is what keeps a fault line meaning a fault.
|
|
671
|
+
if self.down_thread is not None:
|
|
672
|
+
self.down_thread.join(timeout=5)
|
|
673
|
+
if self.capture is not None:
|
|
674
|
+
self._quietly("the microphone", self.capture.close)
|
|
675
|
+
if self.playback is not None:
|
|
676
|
+
self._quietly("the speaker", self.playback.drain)
|
|
677
|
+
self._quietly("the speaker", self.playback.close)
|
|
678
|
+
if self.proc is not None:
|
|
679
|
+
try:
|
|
680
|
+
self.proc.stdin.close()
|
|
681
|
+
except Exception: # noqa: BLE001
|
|
682
|
+
pass
|
|
683
|
+
try:
|
|
684
|
+
self.proc.wait(timeout=10)
|
|
685
|
+
except subprocess.TimeoutExpired:
|
|
686
|
+
self.proc.kill()
|
|
687
|
+
# Said last, because the relay exiting above is what stops the audio still
|
|
688
|
+
# in flight, and through _quietly like every other step here: a playback
|
|
689
|
+
# that cannot answer for its count must not replace the refusal that
|
|
690
|
+
# brought us into close() in the first place.
|
|
691
|
+
self._quietly("the discard count", self._say_dropped)
|
|
692
|
+
|
|
693
|
+
def _say_dropped(self):
|
|
694
|
+
"""Report reply audio the output was no longer open to take.
|
|
695
|
+
|
|
696
|
+
A count read at teardown, which should normally be zero. This has one
|
|
697
|
+
caller and it is the last statement of close(), so the count is only ever
|
|
698
|
+
reported at the end of a session; the tripwire is still worth keeping,
|
|
699
|
+
because a close() added anywhere else would be counted here too. Both
|
|
700
|
+
output paths count it rather than only the file one. Diagnostic only: it
|
|
701
|
+
names nothing in the record and decides no exit code.
|
|
702
|
+
|
|
703
|
+
Read straight off the playback rather than through a default, so a playback
|
|
704
|
+
that cannot answer is a failure rather than a zero indistinguishable from
|
|
705
|
+
having measured none. The None check is close()'s own, for the startup that
|
|
706
|
+
refused before there was an output at all.
|
|
707
|
+
"""
|
|
708
|
+
if self.playback is None:
|
|
709
|
+
return
|
|
710
|
+
dropped = self.playback.discarded
|
|
711
|
+
if dropped:
|
|
712
|
+
log(self.verbose,
|
|
713
|
+
"discarded {} reply audio chunk(s) that arrived after the output "
|
|
714
|
+
"was released".format(dropped))
|
|
715
|
+
|
|
716
|
+
# -------------------------------------------------------------------- threads
|
|
717
|
+
|
|
718
|
+
def _sender(self):
|
|
719
|
+
"""Own the whole uplink, so nothing on it can be sent out of order.
|
|
720
|
+
|
|
721
|
+
Every frame a turn consists of goes through this one queue, talk start
|
|
722
|
+
included. Sending the start from the turn thread instead cost a turn: a
|
|
723
|
+
turn that ends with no answer to wait for - a failed turn, or the model
|
|
724
|
+
finishing with the session - returns as soon as it is told, while the last
|
|
725
|
+
chunk and the talk end may still be here. The next talk start would then
|
|
726
|
+
overtake them, the relay would open a fresh session and apply the previous
|
|
727
|
+
turn's talk end to it, and the captain's entire next question was dropped
|
|
728
|
+
as audio arriving with no turn open. It answered a question nobody had
|
|
729
|
+
finished asking.
|
|
730
|
+
|
|
731
|
+
A closed connection is a dead uplink for talk start and talk end just as
|
|
732
|
+
much as for audio, so all three are sent through the same guard. Sending
|
|
733
|
+
the control frames outside it cost the rest of the session: the write
|
|
734
|
+
raised, this thread died with a traceback, and every later turn queued
|
|
735
|
+
frames nobody was left to send, so it waited out the full timeout with
|
|
736
|
+
no answer instead of reporting the lost connection the downlink had
|
|
737
|
+
already seen.
|
|
738
|
+
"""
|
|
739
|
+
while True:
|
|
740
|
+
item = self.up_q.get()
|
|
741
|
+
if item is None:
|
|
742
|
+
return
|
|
743
|
+
if item is START:
|
|
744
|
+
kind, payload = frame.TALK_START, b""
|
|
745
|
+
elif item is END:
|
|
746
|
+
with self.lock:
|
|
747
|
+
self.turn["wire_end"] = time.monotonic()
|
|
748
|
+
kind, payload = frame.TALK_END, b""
|
|
749
|
+
else:
|
|
750
|
+
kind, payload = frame.AUDIO, item
|
|
751
|
+
try:
|
|
752
|
+
self.uplink.send(kind, payload)
|
|
753
|
+
except (BrokenPipeError, OSError):
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
def _unfinished(self, subject):
|
|
757
|
+
"""Name a fault that landed on an open turn, in the words that turn earned.
|
|
758
|
+
|
|
759
|
+
A relay dies mid-turn in two shapes and they are not the same fault. With
|
|
760
|
+
no reply audio yet, the turn went unanswered. With some already played,
|
|
761
|
+
the captain heard the start of an answer and the rest was cut off, so the
|
|
762
|
+
turn WAS answered and first_audio_s is a real measurement of when: saying
|
|
763
|
+
nothing arrived would contradict the answered field two lines below it in
|
|
764
|
+
the same record, and a reader who believes the wrong one goes looking in
|
|
765
|
+
the wrong place.
|
|
766
|
+
|
|
767
|
+
Read off the same count answered is read off, so the two cannot disagree
|
|
768
|
+
about one turn whatever the timing.
|
|
769
|
+
"""
|
|
770
|
+
if self.playback.turn_bytes > 0:
|
|
771
|
+
return "{} before the reply finished".format(subject)
|
|
772
|
+
return "{} before this turn was answered".format(subject)
|
|
773
|
+
|
|
774
|
+
def _downlink(self):
|
|
775
|
+
# Why the loop stopped, for a turn that was still waiting for its reply
|
|
776
|
+
# when it did. Neither of the quiet exits below raises, and they are
|
|
777
|
+
# different faults, so each names itself rather than leaving the tail to
|
|
778
|
+
# guess or to say nothing.
|
|
779
|
+
why = None
|
|
780
|
+
# And what run() says about the runs that were lost to it. Separate from
|
|
781
|
+
# the reason above because they are different statements: that one is why
|
|
782
|
+
# this turn has no answer, this one is why there will be no more turns.
|
|
783
|
+
cause = None
|
|
784
|
+
while True:
|
|
785
|
+
try:
|
|
786
|
+
got = self.reader.read()
|
|
787
|
+
except (frame.FrameError, OSError) as exc:
|
|
788
|
+
say("client: connection lost: {}".format(exc))
|
|
789
|
+
# Recorded as well as said, because the turn record is what a
|
|
790
|
+
# latency figure is read from later and stderr is not. A dropped
|
|
791
|
+
# connection that only says answered: false is indistinguishable
|
|
792
|
+
# there from a turn the model declined to answer. setdefault
|
|
793
|
+
# because a relay that named the failure first said it better.
|
|
794
|
+
#
|
|
795
|
+
# closed is set in this same critical section, not left to the
|
|
796
|
+
# tail below, because take_turn decides whether another turn can
|
|
797
|
+
# be opened by reading it under this lock. Naming the failure
|
|
798
|
+
# first and announcing the closure afterwards left a window where
|
|
799
|
+
# the connection was known gone and no reader could tell.
|
|
800
|
+
#
|
|
801
|
+
# The reply_done test is the one the quiet close paths below
|
|
802
|
+
# already apply, and it is here for the same reason: a reason
|
|
803
|
+
# belongs to a turn that has not had its answer yet. Without it a
|
|
804
|
+
# fault landing in the gap between reply_end arriving and the
|
|
805
|
+
# record being copied named a turn that was fully answered, and
|
|
806
|
+
# since a recorded reason exits non-zero that failed a session
|
|
807
|
+
# which had delivered everything asked of it. An end of stream and
|
|
808
|
+
# a reset differ only in what the kernel handed us, so they must
|
|
809
|
+
# not produce two different exit codes for one relay death.
|
|
810
|
+
#
|
|
811
|
+
# WHAT THE TEST MAKES INVISIBLE, because it is a real cost rather
|
|
812
|
+
# than none: a relay failure arriving after the FINAL turn's reply
|
|
813
|
+
# was already complete now records no reason and exits 0. The relay
|
|
814
|
+
# puts every audioOutput chunk and the reply_end mark on one
|
|
815
|
+
# ordered queue, so by the time this end sets reply_done every byte
|
|
816
|
+
# of that answer has already reached the playback, and a fault
|
|
817
|
+
# after it cannot have cost the captain any part of what they were
|
|
818
|
+
# given. What it can still cost is a LATER turn, and that is
|
|
819
|
+
# reported with no per-turn reason at all by the remaining-runs
|
|
820
|
+
# check, which exits non-zero whenever the connection is known gone
|
|
821
|
+
# with runs still to take. A relay dying at that instant is also
|
|
822
|
+
# indistinguishable from the same relay dying a moment later during
|
|
823
|
+
# this end's own teardown, which this client already treats as
|
|
824
|
+
# benign. The bound: take_turn clears reply_done in the same
|
|
825
|
+
# critical section as the closure mark, so the blind spot is
|
|
826
|
+
# exactly "after this turn's reply completed" and never "during a
|
|
827
|
+
# turn".
|
|
828
|
+
#
|
|
829
|
+
# closed_because and the closure mark stay outside it, so the
|
|
830
|
+
# session still knows the connection went and still says so.
|
|
831
|
+
with self.lock:
|
|
832
|
+
if not self.reply_done.is_set():
|
|
833
|
+
self.turn.setdefault(
|
|
834
|
+
"failed", "{}: {}".format(
|
|
835
|
+
self._unfinished("the connection was lost"),
|
|
836
|
+
exc))
|
|
837
|
+
self.closed_because = "the connection was lost"
|
|
838
|
+
self.closed.set()
|
|
839
|
+
break
|
|
840
|
+
if got is None:
|
|
841
|
+
if not self.quitting.is_set():
|
|
842
|
+
say("client: the connection ended")
|
|
843
|
+
# The subject only. Whether it ended before the turn was answered
|
|
844
|
+
# or partway through the answer is decided by _unfinished at the
|
|
845
|
+
# tail, where the audio count is read.
|
|
846
|
+
why = "the connection ended"
|
|
847
|
+
cause = "the connection ended"
|
|
848
|
+
break
|
|
849
|
+
kind, payload = got
|
|
850
|
+
# Which turn this frame belongs to, taken the moment it arrives. Every
|
|
851
|
+
# write below applies only while it is still that turn; see turn_id.
|
|
852
|
+
with self.lock:
|
|
853
|
+
arrived_in = self.turn_id
|
|
854
|
+
try:
|
|
855
|
+
if kind == frame.AUDIO:
|
|
856
|
+
with self.lock:
|
|
857
|
+
if arrived_in == self.turn_id:
|
|
858
|
+
now = time.monotonic()
|
|
859
|
+
self.turn.setdefault("first_frame", now)
|
|
860
|
+
self.turn["last_frame"] = now
|
|
861
|
+
# Played whichever turn it belongs to, and told which that is.
|
|
862
|
+
# Late audio is the tail of an answer the captain is still
|
|
863
|
+
# listening to, so dropping it would cut them off, but three
|
|
864
|
+
# figures are read off what this call does - first_played, the
|
|
865
|
+
# reply's own duration and whether the turn was answered at all
|
|
866
|
+
# - and a stale chunk credited to the turn now open reports an
|
|
867
|
+
# unanswered turn as answered, which is an exit code of zero on
|
|
868
|
+
# a session that lost one.
|
|
869
|
+
self.playback.write(payload, arrived_in)
|
|
870
|
+
elif kind == frame.TEXT:
|
|
871
|
+
obj = frame.decode_json(payload)
|
|
872
|
+
text = (obj.get("text") or "").strip()
|
|
873
|
+
if text and not text.startswith("{"):
|
|
874
|
+
who = "you" if obj.get("role") == "USER" else "assistant"
|
|
875
|
+
say(" {}: {}".format(who, text))
|
|
876
|
+
elif kind == frame.NOTICE:
|
|
877
|
+
obj = frame.decode_json(payload)
|
|
878
|
+
event = obj.get("event", "")
|
|
879
|
+
if event == "ready":
|
|
880
|
+
self.ready_notice = obj
|
|
881
|
+
self.ready.set()
|
|
882
|
+
elif event == "queued":
|
|
883
|
+
say(" handed to the first mate: {}".format(
|
|
884
|
+
obj.get("request", "")))
|
|
885
|
+
with self.lock:
|
|
886
|
+
if arrived_in == self.turn_id:
|
|
887
|
+
self.turn["queued"] = obj.get("note_id", "")
|
|
888
|
+
elif event == "interrupted":
|
|
889
|
+
with self.lock:
|
|
890
|
+
if arrived_in == self.turn_id:
|
|
891
|
+
self.turn["interrupted"] = True
|
|
892
|
+
log(self.verbose, "the model treated this turn as an "
|
|
893
|
+
"interruption of its own speech")
|
|
894
|
+
elif event == "turn-failed":
|
|
895
|
+
# The relay is still there and the next talk key gets a
|
|
896
|
+
# new session, so this ends the turn rather than the run.
|
|
897
|
+
say("client: the relay could not finish that turn: {}"
|
|
898
|
+
.format(obj.get("error", "")))
|
|
899
|
+
# Named and released in one critical section, so no turn
|
|
900
|
+
# can be released without also being told why. The
|
|
901
|
+
# reply_done test is the read path's, for the reason given
|
|
902
|
+
# there: a relay whose model stream broke in the gap after
|
|
903
|
+
# this turn's answer completed has cost this turn nothing,
|
|
904
|
+
# and naming it here would fail a session that answered.
|
|
905
|
+
# The release stays outside the test, so a failure arriving
|
|
906
|
+
# while the turn is still waiting still ends its wait.
|
|
907
|
+
with self.lock:
|
|
908
|
+
if arrived_in == self.turn_id:
|
|
909
|
+
if not self.reply_done.is_set():
|
|
910
|
+
self.turn["failed"] = obj.get("error", "")
|
|
911
|
+
self.reply_done.set()
|
|
912
|
+
elif event == "session-ended":
|
|
913
|
+
say("client: the relay ended the session")
|
|
914
|
+
# An ordinary session end is not a turn failure at the
|
|
915
|
+
# relay, and the next talk key still gets a working one. A
|
|
916
|
+
# turn released by it nevertheless has no answer, and
|
|
917
|
+
# relay_error is where the reason for that is read from
|
|
918
|
+
# later, so it carries what the captain was just told. The
|
|
919
|
+
# reply_done test and the setdefault are the tail's, for
|
|
920
|
+
# the tail's reasons.
|
|
921
|
+
with self.lock:
|
|
922
|
+
if arrived_in == self.turn_id:
|
|
923
|
+
if not self.reply_done.is_set():
|
|
924
|
+
self.turn.setdefault(
|
|
925
|
+
"failed",
|
|
926
|
+
self._unfinished(
|
|
927
|
+
"the relay ended the session"))
|
|
928
|
+
self.reply_done.set()
|
|
929
|
+
else:
|
|
930
|
+
log(self.verbose, "notice {}".format(obj))
|
|
931
|
+
elif kind == frame.MARK:
|
|
932
|
+
obj = frame.decode_json(payload)
|
|
933
|
+
with self.lock:
|
|
934
|
+
if arrived_in == self.turn_id:
|
|
935
|
+
self.turn.setdefault(
|
|
936
|
+
"marks", {})[obj.get("mark", "?")] = \
|
|
937
|
+
obj.get("since_talk_end")
|
|
938
|
+
self.turn["tool_calls"] = obj.get("tool_calls", 0)
|
|
939
|
+
if obj.get("mark") == "reply_end":
|
|
940
|
+
self.reply_done.set()
|
|
941
|
+
elif kind == frame.BYE:
|
|
942
|
+
# The same frame ends a session this end asked to end and a
|
|
943
|
+
# relay that stopped on its own, so the frame says nothing on
|
|
944
|
+
# its own and whether we asked is the whole discriminator.
|
|
945
|
+
# Both speak, because a session that ended should say so, and
|
|
946
|
+
# neither borrows the other's words: a line that also appears
|
|
947
|
+
# when everything worked is a line the captain learns to skip,
|
|
948
|
+
# and then the one that means trouble is invisible too.
|
|
949
|
+
if self.quitting.is_set():
|
|
950
|
+
say("client: the relay signed off")
|
|
951
|
+
cause = "the relay signed off after being asked to stop"
|
|
952
|
+
else:
|
|
953
|
+
say("client: the relay stopped without being asked to")
|
|
954
|
+
why = "the relay stopped"
|
|
955
|
+
cause = "the relay stopped without being asked to"
|
|
956
|
+
break
|
|
957
|
+
except Exception as exc: # noqa: BLE001
|
|
958
|
+
# A fault on THIS end, handling a reply that did arrive: the
|
|
959
|
+
# speaker or the output file refusing the audio, or a payload that
|
|
960
|
+
# is not the JSON the wire format promises. Caught as a class
|
|
961
|
+
# rather than as a list, because this handling code can raise
|
|
962
|
+
# something nobody listed, and the failure being removed here is
|
|
963
|
+
# this thread dying silently: closed and reply_done then stay
|
|
964
|
+
# unset, and every remaining run opens a turn, waits out the whole
|
|
965
|
+
# timeout and is recorded unanswered with no reason at all, so one
|
|
966
|
+
# fault costs the session instead of one turn.
|
|
967
|
+
#
|
|
968
|
+
# Deliberately not worded as a lost connection. The connection is
|
|
969
|
+
# fine and naming it would send the captain to the wrong end.
|
|
970
|
+
fault = ("this end could not handle the relay's reply: {}: {}"
|
|
971
|
+
.format(type(exc).__name__, exc))
|
|
972
|
+
say("client: {}".format(fault))
|
|
973
|
+
# The one line is for the captain and the record; the traceback is
|
|
974
|
+
# for whoever has to find the bug behind it. Before this guard
|
|
975
|
+
# existed the thread died and threading.excepthook printed one, so
|
|
976
|
+
# a programming error in here would otherwise be strictly harder to
|
|
977
|
+
# locate than it used to be. Terminal path, so this prints once per
|
|
978
|
+
# session at worst, and the record keeps the one-line reason
|
|
979
|
+
# because that field is machine read.
|
|
980
|
+
sys.stderr.write(traceback.format_exc())
|
|
981
|
+
sys.stderr.flush()
|
|
982
|
+
with self.lock:
|
|
983
|
+
self.turn.setdefault("failed", fault)
|
|
984
|
+
self.closed_because = fault
|
|
985
|
+
self.closed.set()
|
|
986
|
+
break
|
|
987
|
+
# Under the turn lock for the same reason the failure above is: a clean
|
|
988
|
+
# end of file and a goodbye leave the connection just as unusable as a
|
|
989
|
+
# dropped one, and take_turn reads this under that lock to decide whether
|
|
990
|
+
# a turn can still be opened. Already set on the failure path; setting an
|
|
991
|
+
# event twice costs nothing.
|
|
992
|
+
#
|
|
993
|
+
# A turn still waiting for its reply is named in the same critical
|
|
994
|
+
# section, and before the event that releases it, so the turn reading the
|
|
995
|
+
# record finds the reason rather than racing it. reply_done is the test:
|
|
996
|
+
# take_turn clears it under this lock when it opens a turn and it is set
|
|
997
|
+
# at every other moment, so an answered turn whose connection then ends
|
|
998
|
+
# cleanly keeps its record and stays reason-free. setdefault, because a
|
|
999
|
+
# relay that named the failure first said it more precisely than this end
|
|
1000
|
+
# can infer it.
|
|
1001
|
+
with self.lock:
|
|
1002
|
+
if why is not None and not self.reply_done.is_set():
|
|
1003
|
+
self.turn.setdefault("failed", self._unfinished(why))
|
|
1004
|
+
if cause is not None:
|
|
1005
|
+
self.closed_because = cause
|
|
1006
|
+
self.closed.set()
|
|
1007
|
+
self.reply_done.set()
|
|
1008
|
+
|
|
1009
|
+
# ---------------------------------------------------------------------- turns
|
|
1010
|
+
|
|
1011
|
+
def take_turn(self, index):
|
|
1012
|
+
"""Run one turn and return its record, or None if the connection is gone.
|
|
1013
|
+
|
|
1014
|
+
The check and the reset share one critical section with the downlink's
|
|
1015
|
+
closure mark on purpose. The wait between turns is seconds long and is
|
|
1016
|
+
where a relay that dies between questions dies, so the run loop cannot
|
|
1017
|
+
decide to open another turn by reading a flag the downlink sets after it
|
|
1018
|
+
records the failure: between those two writes the connection is already
|
|
1019
|
+
gone and the loop cannot see it. It then cleared the failure the downlink
|
|
1020
|
+
had recorded, sent talk-start into a dead pipe, and came back after the
|
|
1021
|
+
whole reply timeout as answered: false with relay_error: null - a lost
|
|
1022
|
+
connection wearing the shape of a turn the model declined, in the file
|
|
1023
|
+
docs/voice-relay.md computes its published latency spread from.
|
|
1024
|
+
"""
|
|
1025
|
+
with self.lock:
|
|
1026
|
+
if self.closed.is_set():
|
|
1027
|
+
return None
|
|
1028
|
+
self.turn = {}
|
|
1029
|
+
# Advanced here, with the reset it names, so a frame still being
|
|
1030
|
+
# handled from the previous turn can tell that its turn is over.
|
|
1031
|
+
self.turn_id += 1
|
|
1032
|
+
# In the same critical section as the closure mark, because this
|
|
1033
|
+
# event is how the downlink tells a turn waiting for a reply from the
|
|
1034
|
+
# space between turns. Cleared outside the lock it leaves a window
|
|
1035
|
+
# where the connection has already gone, the downlink has read the
|
|
1036
|
+
# event as nobody waiting and named nothing, and this turn then waits
|
|
1037
|
+
# out its whole timeout to be recorded with no reason at all.
|
|
1038
|
+
self.reply_done.clear()
|
|
1039
|
+
# In the same critical section, and named with the same identity the
|
|
1040
|
+
# frames carry, so there is no instant where the turn has advanced and
|
|
1041
|
+
# the playback is still counting audio toward the turn before it.
|
|
1042
|
+
self.playback.turn_reset(self.turn_id)
|
|
1043
|
+
self.up_q.put(START)
|
|
1044
|
+
|
|
1045
|
+
# Unreachable while parse_args refuses open-mic, and kept so that turning
|
|
1046
|
+
# the mode on later is a small change. It is still missing the turn
|
|
1047
|
+
# boundary: it opens the gate and nothing ever closes it, so no talk end
|
|
1048
|
+
# is ever sent. Do not lift the refusal without adding that first.
|
|
1049
|
+
if self.options.listen == OPEN_MIC:
|
|
1050
|
+
release = None
|
|
1051
|
+
self.talking.set()
|
|
1052
|
+
self.capture.begin_turn()
|
|
1053
|
+
say("client: open microphone, run {}. Speak when you like.".format(index))
|
|
1054
|
+
else:
|
|
1055
|
+
release = self._push_to_talk(index)
|
|
1056
|
+
|
|
1057
|
+
deadline = self.options.timeout
|
|
1058
|
+
if not self.reply_done.wait(timeout=deadline):
|
|
1059
|
+
say("client: no reply within {}s".format(deadline))
|
|
1060
|
+
self._wait_audio_quiet(deadline)
|
|
1061
|
+
|
|
1062
|
+
with self.lock:
|
|
1063
|
+
turn = dict(self.turn)
|
|
1064
|
+
marks = turn.get("marks", {})
|
|
1065
|
+
played = self.playback.first_played
|
|
1066
|
+
reply_bytes = self.playback.turn_bytes
|
|
1067
|
+
first_frame = turn.get("first_frame")
|
|
1068
|
+
|
|
1069
|
+
def since(at):
|
|
1070
|
+
if release is None or at is None:
|
|
1071
|
+
return None
|
|
1072
|
+
return round(at - release, 3)
|
|
1073
|
+
|
|
1074
|
+
record = {
|
|
1075
|
+
"run": index,
|
|
1076
|
+
"listen": self.options.listen,
|
|
1077
|
+
"transport": "local" if self.options.local else "ssh",
|
|
1078
|
+
"host": None if self.options.local else self.options.host,
|
|
1079
|
+
"input": self.options.in_file or "microphone",
|
|
1080
|
+
"output": self.options.out_file or "speaker",
|
|
1081
|
+
"model": self.ready_notice.get("model"),
|
|
1082
|
+
"region": self.ready_notice.get("region"),
|
|
1083
|
+
"read_scope": self.ready_notice.get("read_scope"),
|
|
1084
|
+
"connect_seconds": self.ready_notice.get("connect_seconds"),
|
|
1085
|
+
"tool_calls": turn.get("tool_calls", 0),
|
|
1086
|
+
"queued_note": turn.get("queued"),
|
|
1087
|
+
"interrupted": bool(turn.get("interrupted")),
|
|
1088
|
+
# Why a turn has no answer, when either end knows: the relay names a
|
|
1089
|
+
# failed turn, and this end names a connection that went during one.
|
|
1090
|
+
# A results file that only says answered: false invites the reader to
|
|
1091
|
+
# average an infrastructure failure into a latency figure.
|
|
1092
|
+
"relay_error": turn.get("failed"),
|
|
1093
|
+
# The number this build exists to produce: the captain stopped
|
|
1094
|
+
# talking, and this many seconds later sound came out.
|
|
1095
|
+
"first_audio_s": since(played if played is not None else first_frame),
|
|
1096
|
+
"first_frame_s": since(first_frame),
|
|
1097
|
+
"first_played_s": since(played),
|
|
1098
|
+
"last_frame_s": since(turn.get("last_frame")),
|
|
1099
|
+
"uplink_drain_s": since(turn.get("wire_end")),
|
|
1100
|
+
"device_output_latency_s": self.playback.device_latency,
|
|
1101
|
+
"device_input_latency_s": self.capture.device_latency,
|
|
1102
|
+
"relay_marks_since_talk_end": marks,
|
|
1103
|
+
# This turn's own audio, counted by the playback rather than by
|
|
1104
|
+
# subtracting a byte total it shares with every other turn. A total
|
|
1105
|
+
# cannot tell a reply from the previous reply's tail arriving late, and
|
|
1106
|
+
# counting that tail here reports a turn nobody answered as answered.
|
|
1107
|
+
"reply_audio_seconds": round(
|
|
1108
|
+
reply_bytes / float(OUT_RATE * 2), 3),
|
|
1109
|
+
"answered": reply_bytes > 0,
|
|
1110
|
+
}
|
|
1111
|
+
if release is None:
|
|
1112
|
+
record["first_audio_note"] = (
|
|
1113
|
+
"An open microphone has no local end of speech, so the model's "
|
|
1114
|
+
"own detector is the only clock. Read "
|
|
1115
|
+
"relay_marks_since_talk_end instead.")
|
|
1116
|
+
elif not self.options.out_file:
|
|
1117
|
+
record["first_audio_note"] = (
|
|
1118
|
+
"Measured to the moment audio was handed to the output device. "
|
|
1119
|
+
"The device's own buffer, reported as "
|
|
1120
|
+
"device_output_latency_s, comes after that.")
|
|
1121
|
+
else:
|
|
1122
|
+
record["first_audio_note"] = (
|
|
1123
|
+
"Measured to the moment reply audio reached this process. There "
|
|
1124
|
+
"is no speaker in this configuration, so no playback latency is "
|
|
1125
|
+
"included.")
|
|
1126
|
+
return record
|
|
1127
|
+
|
|
1128
|
+
def _wait_audio_quiet(self, deadline):
|
|
1129
|
+
"""Wait for the reply audio to stop arriving before reading the turn.
|
|
1130
|
+
|
|
1131
|
+
Measured, the last audio frame and END_TURN land within about ten
|
|
1132
|
+
milliseconds of each other, audio first, so this almost always returns
|
|
1133
|
+
at once. It is here because the count of reply audio is what the
|
|
1134
|
+
no-overlap wait below depends on, and a turn that ends any other way,
|
|
1135
|
+
such as the session closing, would otherwise be counted short.
|
|
1136
|
+
"""
|
|
1137
|
+
limit = time.monotonic() + deadline
|
|
1138
|
+
while time.monotonic() < limit:
|
|
1139
|
+
with self.lock:
|
|
1140
|
+
last = self.turn.get("last_frame")
|
|
1141
|
+
if last is None:
|
|
1142
|
+
return
|
|
1143
|
+
if time.monotonic() - last >= self.options.audio_idle:
|
|
1144
|
+
return
|
|
1145
|
+
time.sleep(0.05)
|
|
1146
|
+
|
|
1147
|
+
def _push_to_talk(self, index):
|
|
1148
|
+
"""Open the gate, close it, and return the moment the captain stopped.
|
|
1149
|
+
|
|
1150
|
+
That instant, not the moment the last byte reaches the wire, is what the
|
|
1151
|
+
captain experiences as the end of their own speech. Every headline number
|
|
1152
|
+
is measured from it, and uplink_drain_s reports the difference so a slow
|
|
1153
|
+
connection stays visible rather than hiding inside the total.
|
|
1154
|
+
"""
|
|
1155
|
+
seconds = self.options.talk_seconds
|
|
1156
|
+
if seconds is None and not self.options.in_file:
|
|
1157
|
+
try:
|
|
1158
|
+
input("\nrun {}: press Enter, speak, then press Enter again.".format(
|
|
1159
|
+
index))
|
|
1160
|
+
except EOFError:
|
|
1161
|
+
raise SystemExit(
|
|
1162
|
+
"fm-voice-client: no keyboard on this input. Use "
|
|
1163
|
+
"--talk-seconds or --in-file for an unattended run.")
|
|
1164
|
+
|
|
1165
|
+
self.talking.set()
|
|
1166
|
+
self.capture.begin_turn()
|
|
1167
|
+
if seconds is not None:
|
|
1168
|
+
say("client: run {}, capturing {}s.".format(index, seconds))
|
|
1169
|
+
time.sleep(seconds)
|
|
1170
|
+
elif self.options.in_file:
|
|
1171
|
+
self.capture.wait_exhausted(self.options.timeout)
|
|
1172
|
+
else:
|
|
1173
|
+
say(" listening. Enter to send.")
|
|
1174
|
+
try:
|
|
1175
|
+
input()
|
|
1176
|
+
except EOFError:
|
|
1177
|
+
pass
|
|
1178
|
+
|
|
1179
|
+
self.talking.clear()
|
|
1180
|
+
release = time.monotonic()
|
|
1181
|
+
self.up_q.put(END)
|
|
1182
|
+
log(self.verbose, "talk end queued")
|
|
1183
|
+
return release
|
|
1184
|
+
|
|
1185
|
+
def _let_reply_finish(self, record):
|
|
1186
|
+
"""Wait for the previous answer to finish before opening another turn.
|
|
1187
|
+
|
|
1188
|
+
The model tracks its own speech, and audio arriving while it believes it
|
|
1189
|
+
is still talking is an interruption: it emits an INTERRUPTED marker, and
|
|
1190
|
+
the interrupted turn is then lost. It goes as far as calling the tool and
|
|
1191
|
+
then produces no answer at all, which is the worst of both, so this is
|
|
1192
|
+
not an inconvenience to be tolerated.
|
|
1193
|
+
|
|
1194
|
+
The clock that matters runs from the END of generation, not the start.
|
|
1195
|
+
The model streams a six second answer in about one second, and a turn
|
|
1196
|
+
opened at first-frame plus six seconds was still interrupted, while
|
|
1197
|
+
last-frame plus six seconds was not. So the wait is the reply's own
|
|
1198
|
+
duration measured from the last frame, plus a beat. In conversation that
|
|
1199
|
+
costs nothing: it is exactly the pause a captain takes anyway, because
|
|
1200
|
+
they are listening to the answer.
|
|
1201
|
+
|
|
1202
|
+
Barge-in is step three of the design, so until it is built a turn waits.
|
|
1203
|
+
--no-wait-for-reply reproduces the trap deliberately.
|
|
1204
|
+
"""
|
|
1205
|
+
if not self.options.wait_for_reply:
|
|
1206
|
+
return
|
|
1207
|
+
self.playback.drain()
|
|
1208
|
+
with self.lock:
|
|
1209
|
+
last = self.turn.get("last_frame")
|
|
1210
|
+
seconds = record.get("reply_audio_seconds") or 0
|
|
1211
|
+
if last is None or not seconds:
|
|
1212
|
+
return
|
|
1213
|
+
remaining = last + seconds + self.options.gap_seconds - time.monotonic()
|
|
1214
|
+
if remaining > 0:
|
|
1215
|
+
log(self.verbose,
|
|
1216
|
+
"waiting {:.2f}s for the answer to finish".format(remaining))
|
|
1217
|
+
time.sleep(remaining)
|
|
1218
|
+
|
|
1219
|
+
def _say_stopped(self, index):
|
|
1220
|
+
"""Name why no more turns can be taken, and which run was the first lost.
|
|
1221
|
+
|
|
1222
|
+
The cause is whatever the path that closed the connection recorded, not an
|
|
1223
|
+
assertion made here: a fault on this end leaves the connection open, and a
|
|
1224
|
+
line blaming the connection for it sends the captain to the wrong end.
|
|
1225
|
+
"""
|
|
1226
|
+
with self.lock:
|
|
1227
|
+
because = self.closed_because
|
|
1228
|
+
say("client: {} before run {} of {}; it and the rest were not "
|
|
1229
|
+
"taken".format(because, index, self.options.runs))
|
|
1230
|
+
|
|
1231
|
+
def run(self):
|
|
1232
|
+
rc = 0
|
|
1233
|
+
for index in range(1, self.options.runs + 1):
|
|
1234
|
+
record = self.take_turn(index)
|
|
1235
|
+
# take_turn refusing is the one place a closed connection stops the
|
|
1236
|
+
# session, so the outcome is the same wherever the connection went:
|
|
1237
|
+
# nothing more can be taken over it, the runs the captain asked for
|
|
1238
|
+
# were not, and the exit code says so, because a session that stops
|
|
1239
|
+
# early while reporting success is read later as a complete
|
|
1240
|
+
# measurement. A second check here, on a flag read before the turn
|
|
1241
|
+
# rather than under the lock that guards it, is what let a lost
|
|
1242
|
+
# connection through in the first place; and no record is printed for
|
|
1243
|
+
# a turn that never opened, since an invented turn is the whole thing
|
|
1244
|
+
# being kept out of runs.jsonl.
|
|
1245
|
+
if record is None:
|
|
1246
|
+
self._say_stopped(index)
|
|
1247
|
+
rc = 1
|
|
1248
|
+
break
|
|
1249
|
+
print(json.dumps(record))
|
|
1250
|
+
sys.stdout.flush()
|
|
1251
|
+
# A named reason counts as well as an unanswered turn, and not only
|
|
1252
|
+
# when a later run remains. A relay killed while speaking leaves a
|
|
1253
|
+
# turn that was answered and a record that says why the answer stopped
|
|
1254
|
+
# partway, and at the default of one run that turn cleared all three of
|
|
1255
|
+
# the other paths to a non-zero code and reported the session a
|
|
1256
|
+
# success. A results file whose own record names an infrastructure
|
|
1257
|
+
# failure must not sit behind an exit code that says nothing happened.
|
|
1258
|
+
if not record["answered"] or record["relay_error"]:
|
|
1259
|
+
rc = 1
|
|
1260
|
+
if index < self.options.runs:
|
|
1261
|
+
# Checked after the record and before the wait, because that wait
|
|
1262
|
+
# is seconds long and exists only to avoid interrupting the model's
|
|
1263
|
+
# own speech, which a relay that is already gone cannot be doing.
|
|
1264
|
+
# Waiting it out here left the captain sitting through the last
|
|
1265
|
+
# reply's whole spoken duration before being told the session had
|
|
1266
|
+
# stopped. The exit code is still the unhappy one: the runs asked
|
|
1267
|
+
# for were not taken, whatever the last one reported.
|
|
1268
|
+
if self.closed.is_set():
|
|
1269
|
+
self._say_stopped(index + 1)
|
|
1270
|
+
rc = 1
|
|
1271
|
+
break
|
|
1272
|
+
self._let_reply_finish(record)
|
|
1273
|
+
return rc
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def device_selector(value):
|
|
1277
|
+
"""Return a sounddevice device: an index when the value is digits, a name otherwise.
|
|
1278
|
+
|
|
1279
|
+
sounddevice reads an int as an index into its device list and a str as a
|
|
1280
|
+
substring to match against device names, so an index left as text is looked
|
|
1281
|
+
up as a device literally called "3" and raises. docs/voice-relay.md tells the
|
|
1282
|
+
captain these flags take a name or an index, so both have to arrive typed.
|
|
1283
|
+
"""
|
|
1284
|
+
return int(value) if value.strip().isdigit() else value
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def parse_args(argv):
|
|
1288
|
+
parser = argparse.ArgumentParser(
|
|
1289
|
+
prog="fm-voice-client.py", add_help=True,
|
|
1290
|
+
description=__doc__.splitlines()[0])
|
|
1291
|
+
parser.add_argument("--host")
|
|
1292
|
+
parser.add_argument("--local", action="store_true")
|
|
1293
|
+
parser.add_argument("--relay", default=os.environ.get("FM_VOICE_RELAY"),
|
|
1294
|
+
help="path to fm-voice-relay.py on the desktop; required, "
|
|
1295
|
+
"and FM_VOICE_RELAY sets it for a whole shell")
|
|
1296
|
+
parser.add_argument("--relay-python",
|
|
1297
|
+
default=os.environ.get("FM_VOICE_PYTHON", "python3"))
|
|
1298
|
+
parser.add_argument("--relay-arg", action="append")
|
|
1299
|
+
parser.add_argument("--listen", choices=LISTEN_MODES, default=PUSH_TO_TALK,
|
|
1300
|
+
help="push-to-talk is the default and the only mode that "
|
|
1301
|
+
"runs; open-mic is accepted and refuses until "
|
|
1302
|
+
"end-of-speech detection exists")
|
|
1303
|
+
parser.add_argument("--runs", type=int, default=1)
|
|
1304
|
+
parser.add_argument("--talk-seconds", type=float)
|
|
1305
|
+
parser.add_argument("--in-file")
|
|
1306
|
+
parser.add_argument("--out-file")
|
|
1307
|
+
parser.add_argument("--input-device", type=device_selector)
|
|
1308
|
+
parser.add_argument("--output-device", type=device_selector)
|
|
1309
|
+
parser.add_argument("--timeout", type=float, default=30.0)
|
|
1310
|
+
parser.add_argument("--wait-for-reply", action=argparse.BooleanOptionalAction,
|
|
1311
|
+
default=True,
|
|
1312
|
+
help="wait for each answer to finish being spoken before "
|
|
1313
|
+
"opening the next turn (default on)")
|
|
1314
|
+
parser.add_argument("--gap-seconds", type=float, default=0.5,
|
|
1315
|
+
help="quiet beat after an answer finishes. default 0.5")
|
|
1316
|
+
parser.add_argument("--audio-idle", type=float, default=0.4,
|
|
1317
|
+
help="silence that counts as the reply having stopped "
|
|
1318
|
+
"arriving. default 0.4")
|
|
1319
|
+
parser.add_argument("--verbose", action="store_true")
|
|
1320
|
+
options = parser.parse_args(argv)
|
|
1321
|
+
if bool(options.host) == bool(options.local):
|
|
1322
|
+
parser.error("give exactly one of --host <sshhost> or --local")
|
|
1323
|
+
if not options.relay:
|
|
1324
|
+
parser.error(
|
|
1325
|
+
"say where the relay is: --relay <path to fm-voice-relay.py on the "
|
|
1326
|
+
"desktop>, or set FM_VOICE_RELAY")
|
|
1327
|
+
if options.runs < 1:
|
|
1328
|
+
parser.error("--runs must be at least 1")
|
|
1329
|
+
if options.listen == OPEN_MIC and options.in_file:
|
|
1330
|
+
parser.error(
|
|
1331
|
+
"--listen open-mic with --in-file would end the turn when the file "
|
|
1332
|
+
"ran out, which is not what an open microphone does")
|
|
1333
|
+
if options.listen == OPEN_MIC:
|
|
1334
|
+
# Here rather than in open(), so nothing is spent: no ssh, no relay, no
|
|
1335
|
+
# model session. See the module docstring on the two kinds of listening.
|
|
1336
|
+
parser.error(
|
|
1337
|
+
"--listen open-mic is not built yet: it needs end-of-speech "
|
|
1338
|
+
"detection to know when a turn ended, which lands with session "
|
|
1339
|
+
"continuity across turns, so it would stream forever and never end "
|
|
1340
|
+
"a turn. Use the default --listen push-to-talk.")
|
|
1341
|
+
return options
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def main(argv):
|
|
1345
|
+
options = parse_args(argv)
|
|
1346
|
+
client = Client(options)
|
|
1347
|
+
try:
|
|
1348
|
+
client.open()
|
|
1349
|
+
except SystemExit as exc:
|
|
1350
|
+
# _wait_ready refuses this way and its message is already the whole
|
|
1351
|
+
# story. open() has released what it started; this turns the refusal
|
|
1352
|
+
# into the same one-line exit the rest of this file gives.
|
|
1353
|
+
if exc.code not in (None, 0):
|
|
1354
|
+
sys.stderr.write("{}\n".format(exc.code))
|
|
1355
|
+
return 2
|
|
1356
|
+
except (frame.FrameError, OSError, DeviceError) as exc:
|
|
1357
|
+
sys.stderr.write("fm-voice-client: {}\n".format(exc))
|
|
1358
|
+
return 2
|
|
1359
|
+
except Exception as exc: # noqa: BLE001
|
|
1360
|
+
sys.stderr.write("fm-voice-client: could not start: {}: {}\n".format(
|
|
1361
|
+
type(exc).__name__, exc))
|
|
1362
|
+
return 2
|
|
1363
|
+
try:
|
|
1364
|
+
return client.run()
|
|
1365
|
+
except KeyboardInterrupt:
|
|
1366
|
+
say("client: stopping.")
|
|
1367
|
+
return 130
|
|
1368
|
+
finally:
|
|
1369
|
+
client.close()
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
if __name__ == "__main__":
|
|
1373
|
+
sys.exit(main(sys.argv[1:]))
|