@zerwiz/ymir 0.1.3 → 0.1.5
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/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 +290 -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 +50 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +9 -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/config/tailscale-sync.yaml +27 -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/kaia.engram +0 -0
- package/.agents/memory/kaia.engram-shm +0 -0
- package/.agents/memory/kaia.engram-wal +0 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/memory/well/episodes.jsonl +367 -0
- package/.agents/memory/well/workspace.jsonl +27 -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 +111 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -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 +158 -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 +673 -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 +496 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -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 +161 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -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 +96 -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 +175 -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 +360 -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 +70 -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 +52 -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 +34 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -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 +454 -0
- package/CHANGELOG.md +881 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +113 -39
- 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 +65 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +58 -0
- package/RULES/07-config.md +45 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/README.md +20 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +98 -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 +233 -0
- package/bin/apodex-smoke-test.sh +92 -0
- package/bin/bifrost-bridge.sh +112 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +107 -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 +38 -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-guard.sh +165 -0
- package/bin/crash-sense.sh +160 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +207 -0
- package/bin/eindri-acclaim.sh +34 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seen.sh +41 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +85 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +112 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/fleet-apply.sh +68 -0
- package/bin/github-deploy.sh +81 -0
- package/bin/gjallarhorn-expose.sh +102 -0
- package/bin/gjallarhorn-notify.sh +58 -0
- package/bin/gjallarhorn-purge.sh +54 -0
- package/bin/gjallarhorn-tunnel.sh +72 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +122 -0
- package/bin/hall-snapshot.sh +92 -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 +431 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hoard-lib.sh +25 -0
- package/bin/hodd.sh +78 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +108 -0
- package/bin/local-model-lock.sh +51 -0
- package/bin/mimir-bridge.py +226 -0
- package/bin/mimir-bridge.sh +87 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +59 -0
- package/bin/mjollnir.sh +85 -0
- package/bin/model-hardware.sh +82 -0
- package/bin/model-resolve.sh +147 -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-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +148 -0
- package/bin/omarchy-plugins.sh +174 -0
- package/bin/omarchy-sense.sh +149 -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-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +38 -0
- package/bin/project-git.sh +55 -0
- package/bin/public-guard.sh +70 -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 +91 -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 +90 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +76 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +190 -0
- package/bin/telegram-bot.sh +69 -0
- package/bin/toolchain.sh +74 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +260 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +47 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +150 -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 +641 -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-platform.sh +348 -0
- package/bin/ymir-say.sh +97 -0
- package/bin/ymir-setup-auth.sh +108 -0
- package/bin/ymir-validate.sh +173 -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 +1056 -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 +44 -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 +210 -0
- package/scripts/start.sh +167 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fm-voice-relay.py - hold the Nova Sonic session on this desktop, on behalf of the laptop.
|
|
3
|
+
|
|
4
|
+
The captain talks into their laptop. The laptop captures audio and streams it
|
|
5
|
+
over the SSH connection it already has to this desktop. This relay holds the
|
|
6
|
+
Bedrock bidirectional session, answers the model's tool calls from firstmate's
|
|
7
|
+
records, and streams the spoken reply back down the same connection. AWS
|
|
8
|
+
credentials therefore stay on this desktop and never go near the laptop, which
|
|
9
|
+
is the whole reason for the shape.
|
|
10
|
+
|
|
11
|
+
The voice agent this relay runs is NOT firstmate. It stands in front of
|
|
12
|
+
firstmate: it answers questions about the fleet from the records, and when the
|
|
13
|
+
captain asks for real work it says out loud that it is handing the request over
|
|
14
|
+
and then queues it. It never claims to have done the work.
|
|
15
|
+
|
|
16
|
+
Modes:
|
|
17
|
+
--serve read fm_voice_frame frames on stdin, write them on stdout.
|
|
18
|
+
This is what the laptop client runs over SSH, and the
|
|
19
|
+
default when no mode is given.
|
|
20
|
+
--self-test FILE feed one raw 16 kHz PCM file into a session as if it had
|
|
21
|
+
arrived from the client, print the timings as JSON, exit.
|
|
22
|
+
This is the control measurement for the relay path, and it
|
|
23
|
+
needs no client, no SSH and no microphone.
|
|
24
|
+
|
|
25
|
+
The two traps this code already avoids, both found the expensive way and both
|
|
26
|
+
measured rather than assumed:
|
|
27
|
+
|
|
28
|
+
1. completionEnd does not arrive on its own. The model holds the session open
|
|
29
|
+
waiting for more speech. The real "the reply is finished" signal is a
|
|
30
|
+
contentEnd carrying stopReason END_TURN.
|
|
31
|
+
2. Audio with no trailing silence is truncated and never answered, even when
|
|
32
|
+
contentEnd follows immediately. A push-to-talk release supplies no trailing
|
|
33
|
+
silence at all, so this relay appends its own on talk end. --tail-ms sets
|
|
34
|
+
how much. Measured here, the tail is a content requirement and not a time
|
|
35
|
+
one: nothing was answered at 0 or 100 ms, everything was answered from
|
|
36
|
+
200 ms up, and 200 through 800 ms all landed in the same spread because the
|
|
37
|
+
silence is sent unpaced. The 400 ms default is margin that costs nothing.
|
|
38
|
+
|
|
39
|
+
Read scope, deny list and the handover queue all belong to bin/fm_voice_records.py.
|
|
40
|
+
bin/fm_voice_frame.py owns the wire contract between the two machines, and
|
|
41
|
+
docs/voice-relay.md is the operator-facing guide.
|
|
42
|
+
|
|
43
|
+
CONFIGURATION. The region, the model and the AWS profile name somebody's account
|
|
44
|
+
and somebody's choices, so this file carries no default for them. Each is read
|
|
45
|
+
from the home's gitignored config/ directory, or from the matching environment
|
|
46
|
+
variable, and a missing one refuses with the path to write rather than reaching
|
|
47
|
+
for a value that belongs to another home. That configuration is also the opt-in:
|
|
48
|
+
an unconfigured home cannot start this relay at all.
|
|
49
|
+
|
|
50
|
+
config/voice-region FM_VOICE_REGION Bedrock region. required
|
|
51
|
+
config/voice-model FM_VOICE_MODEL Nova Sonic model id. required
|
|
52
|
+
config/voice-profile FM_VOICE_PROFILE AWS profile. optional
|
|
53
|
+
config/voice-id FM_VOICE_ID output voice. default matthew
|
|
54
|
+
|
|
55
|
+
An absent profile means the relay uses only credentials that are already in its
|
|
56
|
+
environment. An empty FM_VOICE_PROFILE, or an empty `--profile ""`, forces that
|
|
57
|
+
even when config/voice-profile exists.
|
|
58
|
+
|
|
59
|
+
On choosing the model: the first-generation Nova Sonic model is marked legacy by
|
|
60
|
+
AWS and measured 25 percent slower on the tool-backed path, which is the path this
|
|
61
|
+
interface actually uses, so the figures in docs/voice-relay.md were taken against
|
|
62
|
+
the second generation, which that document names.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
fm-voice-relay.py [--serve] [options]
|
|
66
|
+
fm-voice-relay.py --self-test <file.pcm> [options]
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--region <name> Bedrock region. default from config
|
|
70
|
+
--model <id> Nova Sonic model id. default from config
|
|
71
|
+
--profile <name> AWS profile. default from config
|
|
72
|
+
--voice <id> output voice. default matthew
|
|
73
|
+
--home <dir> firstmate home for records. default $FM_HOME or this repo
|
|
74
|
+
--scope <name> override the read scope for this run.
|
|
75
|
+
--tail-ms <int> silence appended on talk end. default 400
|
|
76
|
+
--turn-timeout <sec> how long --self-test waits. default 40
|
|
77
|
+
--verbose log the session to stderr.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
import argparse
|
|
81
|
+
import asyncio
|
|
82
|
+
import base64
|
|
83
|
+
import datetime
|
|
84
|
+
import json
|
|
85
|
+
import os
|
|
86
|
+
import queue
|
|
87
|
+
import subprocess
|
|
88
|
+
import sys
|
|
89
|
+
import threading
|
|
90
|
+
import time
|
|
91
|
+
import traceback
|
|
92
|
+
import uuid
|
|
93
|
+
|
|
94
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
95
|
+
|
|
96
|
+
import fm_voice_frame as frame # noqa: E402
|
|
97
|
+
import fm_voice_records as records # noqa: E402
|
|
98
|
+
|
|
99
|
+
# A voice id names nobody and costs nothing to inherit, so this one has a
|
|
100
|
+
# default. The region, the model and the profile do not; see CONFIGURATION above.
|
|
101
|
+
VOICE = "matthew"
|
|
102
|
+
SETTINGS = {
|
|
103
|
+
"region": ("voice-region", "FM_VOICE_REGION", "Bedrock region"),
|
|
104
|
+
"model": ("voice-model", "FM_VOICE_MODEL", "Nova Sonic model id"),
|
|
105
|
+
"profile": ("voice-profile", "FM_VOICE_PROFILE", "AWS profile"),
|
|
106
|
+
"voice": ("voice-id", "FM_VOICE_ID", "output voice"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
IN_RATE = 16000
|
|
110
|
+
OUT_RATE = 24000
|
|
111
|
+
# 3200 bytes is 100 ms at 16 kHz 16-bit mono, the chunk size earlier prototype
|
|
112
|
+
# work measured its timings with. Keeping it identical keeps those comparable.
|
|
113
|
+
CHUNK = 3200
|
|
114
|
+
BYTES_PER_MS_IN = IN_RATE * 2 // 1000
|
|
115
|
+
|
|
116
|
+
# Push-to-talk supplies no trailing silence, and trap 2 above means a turn with
|
|
117
|
+
# none is never answered. 400 ms is the measured floor plus one chunk of margin;
|
|
118
|
+
# see docs/voice-relay.md for the runs behind it.
|
|
119
|
+
TAIL_MS = 400
|
|
120
|
+
|
|
121
|
+
SYSTEM_PROMPT = (
|
|
122
|
+
"You are the captain's voice assistant. You are NOT the first mate, and you "
|
|
123
|
+
"must never claim to be. You stand in front of the first mate and you are "
|
|
124
|
+
"the captain's spoken way of reaching it.\n"
|
|
125
|
+
"\n"
|
|
126
|
+
"When the captain asks how things are going, what is in flight, what is "
|
|
127
|
+
"waiting on them, or whether anything is ready to review, call "
|
|
128
|
+
"get_fleet_status and answer from what it returns. Give counts and at most a "
|
|
129
|
+
"couple of names. Never invent a number, a name or a pull request. If the "
|
|
130
|
+
"tool says detail is withheld, say the detail is not available by voice.\n"
|
|
131
|
+
"\n"
|
|
132
|
+
"Call get_fleet_status every single time the captain asks, including when "
|
|
133
|
+
"they asked a moment ago. The records change while you are talking, and an "
|
|
134
|
+
"answer repeated from memory is a stale answer given confidently, which is "
|
|
135
|
+
"worse than a slow one.\n"
|
|
136
|
+
"\n"
|
|
137
|
+
"When the captain asks for actual work, anything that would change code, "
|
|
138
|
+
"open a pull request, investigate a bug, or start a job, you do not do it "
|
|
139
|
+
"and you do not pretend to. Say out loud that you are handing it to the "
|
|
140
|
+
"first mate, then call hand_over_to_firstmate with the captain's request in "
|
|
141
|
+
"their own words. Then confirm it is queued. Never say you have done, "
|
|
142
|
+
"started, fixed or built anything yourself.\n"
|
|
143
|
+
"\n"
|
|
144
|
+
"Speak in one or two short sentences. You are being listened to, not read."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
TOOLS = {"tools": [
|
|
148
|
+
{"toolSpec": {
|
|
149
|
+
"name": "get_fleet_status",
|
|
150
|
+
"description": (
|
|
151
|
+
"Read the first mate's durable records: how many jobs are in "
|
|
152
|
+
"flight, how many decisions are waiting on the captain, how many "
|
|
153
|
+
"pull requests are open, and the names of a few of them."),
|
|
154
|
+
"inputSchema": {"json": json.dumps(
|
|
155
|
+
{"type": "object", "properties": {}, "required": []})},
|
|
156
|
+
}},
|
|
157
|
+
{"toolSpec": {
|
|
158
|
+
"name": "hand_over_to_firstmate",
|
|
159
|
+
"description": (
|
|
160
|
+
"Hand a request for real work to the first mate, which will pick it "
|
|
161
|
+
"up at its next check. Use this for anything you cannot answer from "
|
|
162
|
+
"the records. It queues the request and does not do the work."),
|
|
163
|
+
"inputSchema": {"json": json.dumps({
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {"request": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"description": "The captain's request, in the captain's own words.",
|
|
168
|
+
}},
|
|
169
|
+
"required": ["request"],
|
|
170
|
+
})},
|
|
171
|
+
}},
|
|
172
|
+
]}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def log(enabled, message):
|
|
176
|
+
if enabled:
|
|
177
|
+
sys.stderr.write("relay: {}\n".format(message))
|
|
178
|
+
sys.stderr.flush()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def widen_path():
|
|
182
|
+
"""Put the toolbox directories on PATH, as bin/fm-inbox.sh does and for the same reason.
|
|
183
|
+
|
|
184
|
+
`ssh host command` gets no login shell, so it gets no ~/.toolbox/bin. The
|
|
185
|
+
sandbox profile's credential_process is the bare word `ada`, so without this
|
|
186
|
+
the relay starts, connects to nothing, and reports a missing file. That is
|
|
187
|
+
the normal way this relay is launched, so it has to hold here.
|
|
188
|
+
"""
|
|
189
|
+
extra = [os.path.expanduser(p) for p in ("~/.toolbox/bin", "~/.local/bin")]
|
|
190
|
+
parts = os.environ.get("PATH", "").split(os.pathsep)
|
|
191
|
+
added = [p for p in extra if os.path.isdir(p) and p not in parts]
|
|
192
|
+
if added:
|
|
193
|
+
os.environ["PATH"] = os.pathsep.join(added + parts)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# A credential that states an expiry this interpreter cannot read. The
|
|
197
|
+
# credential itself is fine; only its deadline is unknown, and that is not the
|
|
198
|
+
# same thing as not having one.
|
|
199
|
+
EXPIRY_UNKNOWN = object()
|
|
200
|
+
|
|
201
|
+
# Where a set of credentials came from. The difference matters to the cache: the
|
|
202
|
+
# profile can be asked again for fresher credentials, and the environment of an
|
|
203
|
+
# already-running process cannot.
|
|
204
|
+
FROM_ENVIRONMENT = "environment"
|
|
205
|
+
FROM_PROFILE = "profile"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class CredentialError(Exception):
|
|
209
|
+
"""No usable AWS credentials, and the caller is told which door was tried.
|
|
210
|
+
|
|
211
|
+
An ordinary exception rather than SystemExit, because credentials are now
|
|
212
|
+
resolved lazily and a refresh can therefore land in the middle of a turn.
|
|
213
|
+
SystemExit would walk straight through the turn boundary in
|
|
214
|
+
handle_uplink_frame and end the relay over one bad refresh, which is the
|
|
215
|
+
failure that boundary exists to absorb.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _expires_at(stamp):
|
|
220
|
+
"""Return the expiry as epoch seconds, None when there is none, or EXPIRY_UNKNOWN.
|
|
221
|
+
|
|
222
|
+
The two failure shapes mean opposite things and must not collapse into one.
|
|
223
|
+
No Expiration at all is a credential that does not expire. An Expiration
|
|
224
|
+
that will not parse, such as an offset written +0000 on an interpreter older
|
|
225
|
+
than 3.11, is a credential that does expire at a moment this process cannot
|
|
226
|
+
read, and treating that as "never" would cache it past its real deadline and
|
|
227
|
+
fail every session from then on.
|
|
228
|
+
"""
|
|
229
|
+
if not stamp:
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
when = datetime.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
|
|
233
|
+
except ValueError:
|
|
234
|
+
return EXPIRY_UNKNOWN
|
|
235
|
+
if when.tzinfo is None:
|
|
236
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
237
|
+
return when.timestamp()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def ambient_credentials(verbose=False, margin=0, only_source=False):
|
|
241
|
+
"""Return (credentials, expiry) from the environment, or None if it has none to give.
|
|
242
|
+
|
|
243
|
+
None means "ask the profile instead", and there are three ways to get it.
|
|
244
|
+
An environment with no key id at all is the ordinary ssh case. One carrying
|
|
245
|
+
a key id without a secret beside it is a half-set variable, which is a
|
|
246
|
+
mistake worth naming rather than a KeyError from inside a worker thread.
|
|
247
|
+
And one whose AWS_CREDENTIAL_EXPIRATION has passed, or passes within margin
|
|
248
|
+
seconds, is no longer usable: os.environ cannot get fresher values while
|
|
249
|
+
this process runs, so the only way forward is the profile.
|
|
250
|
+
|
|
251
|
+
only_source says there is no profile to ask, which changes what a passed
|
|
252
|
+
deadline means. The environment is then the only place a credential can come
|
|
253
|
+
from, so a stale one is still the best answer available, and refusing it
|
|
254
|
+
would end a live conversation over something only the operator can refresh.
|
|
255
|
+
AWS says so itself if the credential really is dead. An environment with no
|
|
256
|
+
keys in it at all is a refusal either way.
|
|
257
|
+
|
|
258
|
+
Temporary credentials with no stated deadline are reported as
|
|
259
|
+
EXPIRY_UNKNOWN rather than as eternal, because a session token always has a
|
|
260
|
+
deadline whether or not the shell that exported it said so.
|
|
261
|
+
"""
|
|
262
|
+
key = os.environ.get("AWS_ACCESS_KEY_ID")
|
|
263
|
+
if not key:
|
|
264
|
+
return None
|
|
265
|
+
secret = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
|
266
|
+
if not secret:
|
|
267
|
+
log(verbose, "AWS_ACCESS_KEY_ID is set with no AWS_SECRET_ACCESS_KEY "
|
|
268
|
+
"beside it, so the environment is being ignored")
|
|
269
|
+
return None
|
|
270
|
+
token = os.environ.get("AWS_SESSION_TOKEN")
|
|
271
|
+
expires = _expires_at(os.environ.get("AWS_CREDENTIAL_EXPIRATION"))
|
|
272
|
+
if expires is None and token:
|
|
273
|
+
expires = EXPIRY_UNKNOWN
|
|
274
|
+
if (not only_source and expires not in (None, EXPIRY_UNKNOWN)
|
|
275
|
+
and time.time() + margin >= expires):
|
|
276
|
+
log(verbose, "the credentials in the environment have expired")
|
|
277
|
+
return None
|
|
278
|
+
log(verbose, "using credentials already in the environment")
|
|
279
|
+
return {
|
|
280
|
+
"aws_access_key_id": key,
|
|
281
|
+
"aws_secret_access_key": secret,
|
|
282
|
+
"aws_session_token": token,
|
|
283
|
+
}, expires
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def profile_credentials(profile, verbose=False):
|
|
287
|
+
"""Return (credentials, expiry) exported from an AWS profile, or refuse by name."""
|
|
288
|
+
if not profile:
|
|
289
|
+
raise CredentialError(
|
|
290
|
+
"no credentials in the environment and no AWS profile configured: "
|
|
291
|
+
"write one into config/voice-profile, set FM_VOICE_PROFILE, or "
|
|
292
|
+
"export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY")
|
|
293
|
+
log(verbose, "exporting credentials from profile {}".format(profile))
|
|
294
|
+
widen_path()
|
|
295
|
+
done = subprocess.run(
|
|
296
|
+
["aws", "configure", "export-credentials", "--profile", profile,
|
|
297
|
+
"--format", "process"],
|
|
298
|
+
# The relay's own stdin is the captain's audio in --serve mode. A child
|
|
299
|
+
# that read it would eat frames and desynchronise the uplink, so no
|
|
300
|
+
# child gets it.
|
|
301
|
+
stdin=subprocess.DEVNULL,
|
|
302
|
+
capture_output=True, text=True, timeout=60, check=False)
|
|
303
|
+
if done.returncode != 0:
|
|
304
|
+
raise CredentialError(
|
|
305
|
+
"could not get credentials for profile {}: {}".format(
|
|
306
|
+
profile, (done.stderr or done.stdout).strip()))
|
|
307
|
+
blob = json.loads(done.stdout)
|
|
308
|
+
return {
|
|
309
|
+
"aws_access_key_id": blob["AccessKeyId"],
|
|
310
|
+
"aws_secret_access_key": blob["SecretAccessKey"],
|
|
311
|
+
"aws_session_token": blob.get("SessionToken"),
|
|
312
|
+
}, _expires_at(blob.get("Expiration"))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def resolve_credentials(profile, verbose=False, margin=0, allow_ambient=True):
|
|
316
|
+
"""Return (credentials, expiry, source), preferring the environment when allowed.
|
|
317
|
+
|
|
318
|
+
The sandbox profile's credential_process costs about a second, so ambient
|
|
319
|
+
credentials win while they are usable. It also blocks the caller for that
|
|
320
|
+
second, so Credentials below owns when this runs and keeps it out of a turn.
|
|
321
|
+
The source is reported because only one of the two can be asked again for
|
|
322
|
+
something fresher, and the cache has to know which it is holding.
|
|
323
|
+
|
|
324
|
+
A relay with no profile at all is a supported shape, so the environment gets
|
|
325
|
+
a second look when there is nothing to escalate to. Giving up on the only
|
|
326
|
+
source there is would turn "these credentials are getting old" into "this
|
|
327
|
+
relay is over", which is a worse answer than handing over keys that AWS can
|
|
328
|
+
refuse for itself.
|
|
329
|
+
"""
|
|
330
|
+
if allow_ambient:
|
|
331
|
+
ambient = ambient_credentials(verbose, margin)
|
|
332
|
+
if ambient is None and not profile:
|
|
333
|
+
ambient = ambient_credentials(verbose, margin, only_source=True)
|
|
334
|
+
if ambient is not None:
|
|
335
|
+
log(verbose, "keeping the credentials in the environment anyway: "
|
|
336
|
+
"there is no profile to fall back to")
|
|
337
|
+
if ambient is not None:
|
|
338
|
+
return ambient[0], ambient[1], FROM_ENVIRONMENT
|
|
339
|
+
try:
|
|
340
|
+
creds, expires = profile_credentials(profile, verbose)
|
|
341
|
+
except CredentialError:
|
|
342
|
+
# The profile was the escalation and it refused. Whatever the environment
|
|
343
|
+
# still holds is older than we would like, which is why the profile was
|
|
344
|
+
# asked at all, but it is a real answer and AWS refuses it for itself if
|
|
345
|
+
# it is dead. Ending the conversation instead would spend the captain's
|
|
346
|
+
# session on a preference. An environment with nothing in it re-raises.
|
|
347
|
+
ambient = ambient_credentials(verbose, margin, only_source=True)
|
|
348
|
+
if ambient is None:
|
|
349
|
+
raise
|
|
350
|
+
log(verbose, "the profile refused, so falling back to the credentials "
|
|
351
|
+
"still in the environment")
|
|
352
|
+
return ambient[0], ambient[1], FROM_ENVIRONMENT
|
|
353
|
+
return creds, expires, FROM_PROFILE
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Credentials:
|
|
357
|
+
"""The relay's credentials, resolved once and shared by every session it opens.
|
|
358
|
+
|
|
359
|
+
A session is rebuilt for every turn, on purpose and for a measured reason
|
|
360
|
+
(see renew), so resolving per session would charge the credential_process
|
|
361
|
+
second to each turn after the first. The relay resolves once at start and
|
|
362
|
+
every later session reuses that answer, so a reconnect costs a reconnect
|
|
363
|
+
and not a credential fetch.
|
|
364
|
+
|
|
365
|
+
Credentials that carry an expiry are refreshed a few minutes ahead of it,
|
|
366
|
+
because a relay left running outlives them. One whose expiry cannot be read
|
|
367
|
+
is held for that same margin and no longer, so an unreadable deadline costs
|
|
368
|
+
an occasional resolution rather than every session after the deadline. The
|
|
369
|
+
margin is handed to the resolver as well, because credentials taken from the
|
|
370
|
+
environment cannot be refreshed in place and have to be abandoned for the
|
|
371
|
+
profile once they are that close to the end.
|
|
372
|
+
|
|
373
|
+
That abandonment has to be remembered, not just decided. os.environ never
|
|
374
|
+
gets fresher values while this process runs, so re-reading it after giving up
|
|
375
|
+
on an ambient credential would hand back the same stale keys forever and the
|
|
376
|
+
bound above would be a bound in name only. Once an ambient answer is spent,
|
|
377
|
+
this asks the profile from then on.
|
|
378
|
+
|
|
379
|
+
An ambient answer is only ever spent when there IS a profile to spend it on.
|
|
380
|
+
With no profile the environment is the only source, so the bound becomes a
|
|
381
|
+
re-read of it rather than an escalation: a relay configured that way keeps
|
|
382
|
+
answering, and whether the keys still work is between AWS and the operator
|
|
383
|
+
who exported them.
|
|
384
|
+
|
|
385
|
+
Every resolution, the first one included, runs in a worker thread, so the
|
|
386
|
+
event loop keeps reading the captain's audio while it happens.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
REFRESH_MARGIN = 300
|
|
390
|
+
|
|
391
|
+
def __init__(self, profile, verbose=False):
|
|
392
|
+
self.profile = profile
|
|
393
|
+
self.verbose = verbose
|
|
394
|
+
self._creds = None
|
|
395
|
+
self._expires = None
|
|
396
|
+
self._source = None
|
|
397
|
+
self._resolved = None
|
|
398
|
+
self._ambient_spent = False
|
|
399
|
+
self._lock = asyncio.Lock()
|
|
400
|
+
|
|
401
|
+
def _usable(self):
|
|
402
|
+
if self._creds is None:
|
|
403
|
+
return False
|
|
404
|
+
if self._expires is EXPIRY_UNKNOWN:
|
|
405
|
+
return time.monotonic() - self._resolved < self.REFRESH_MARGIN
|
|
406
|
+
if self._expires is None:
|
|
407
|
+
return True
|
|
408
|
+
return time.time() + self.REFRESH_MARGIN < self._expires
|
|
409
|
+
|
|
410
|
+
async def get(self):
|
|
411
|
+
async with self._lock:
|
|
412
|
+
if not self._usable():
|
|
413
|
+
spend = self._source == FROM_ENVIRONMENT and bool(self.profile)
|
|
414
|
+
creds, expires, source = await asyncio.to_thread(
|
|
415
|
+
resolve_credentials, self.profile, self.verbose,
|
|
416
|
+
self.REFRESH_MARGIN, not (self._ambient_spent or spend))
|
|
417
|
+
# Latched only now, and only if the profile is what answered. A
|
|
418
|
+
# profile that cannot answer raises out of the line above or is
|
|
419
|
+
# answered for by the environment, and latching either of those
|
|
420
|
+
# would abandon credentials this process is still holding on the
|
|
421
|
+
# strength of a source that just refused, turning one failed
|
|
422
|
+
# refresh into every later turn.
|
|
423
|
+
if spend and source == FROM_PROFILE:
|
|
424
|
+
log(self.verbose, "the credentials from the environment are "
|
|
425
|
+
"spent; asking the profile from now on")
|
|
426
|
+
self._ambient_spent = True
|
|
427
|
+
self._creds, self._expires, self._source = creds, expires, source
|
|
428
|
+
self._resolved = time.monotonic()
|
|
429
|
+
return dict(self._creds)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class Downlink:
|
|
433
|
+
"""Write frames to the client from one dedicated thread.
|
|
434
|
+
|
|
435
|
+
A blocking write to a stalled SSH channel must not stop the relay reading
|
|
436
|
+
the captain's audio or the model's output, and the moment a reply byte is
|
|
437
|
+
actually handed to the connection is the only honest place to timestamp it.
|
|
438
|
+
Both of those want the writes off the event loop, so they live here.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def __init__(self, stream):
|
|
442
|
+
self._stream = stream
|
|
443
|
+
self._queue = queue.Queue()
|
|
444
|
+
self._first_audio = None
|
|
445
|
+
self._lock = threading.Lock()
|
|
446
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
447
|
+
self._thread.start()
|
|
448
|
+
|
|
449
|
+
def _run(self):
|
|
450
|
+
writer = frame.Writer(self._stream)
|
|
451
|
+
while True:
|
|
452
|
+
item = self._queue.get()
|
|
453
|
+
if item is None:
|
|
454
|
+
return
|
|
455
|
+
kind, payload = item
|
|
456
|
+
try:
|
|
457
|
+
writer.send(kind, payload)
|
|
458
|
+
except (BrokenPipeError, ValueError, OSError):
|
|
459
|
+
return
|
|
460
|
+
if kind == frame.AUDIO:
|
|
461
|
+
with self._lock:
|
|
462
|
+
if self._first_audio is None:
|
|
463
|
+
self._first_audio = time.monotonic()
|
|
464
|
+
|
|
465
|
+
def send(self, kind, payload=b""):
|
|
466
|
+
self._queue.put((kind, payload))
|
|
467
|
+
|
|
468
|
+
def send_json(self, kind, obj):
|
|
469
|
+
self.send(kind, json.dumps(obj, separators=(",", ":")).encode("utf-8"))
|
|
470
|
+
|
|
471
|
+
def arm_turn(self):
|
|
472
|
+
"""Forget the previous turn's first-audio mark."""
|
|
473
|
+
with self._lock:
|
|
474
|
+
self._first_audio = None
|
|
475
|
+
|
|
476
|
+
def first_audio(self):
|
|
477
|
+
with self._lock:
|
|
478
|
+
return self._first_audio
|
|
479
|
+
|
|
480
|
+
def close(self):
|
|
481
|
+
self._queue.put(None)
|
|
482
|
+
self._thread.join(timeout=5)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class Session:
|
|
486
|
+
"""One Nova Sonic bidirectional session, plus the turn bookkeeping around it."""
|
|
487
|
+
|
|
488
|
+
def __init__(self, options, down, credentials):
|
|
489
|
+
self.options = options
|
|
490
|
+
self.down = down
|
|
491
|
+
self.credentials = credentials
|
|
492
|
+
self.verbose = options.verbose
|
|
493
|
+
self.prompt = str(uuid.uuid4())
|
|
494
|
+
self.stream = None
|
|
495
|
+
self.reader_task = None
|
|
496
|
+
self.audio_content = None
|
|
497
|
+
self.turn = {}
|
|
498
|
+
self.tool_calls = 0
|
|
499
|
+
# Replies this session has finished. One is the most it should ever
|
|
500
|
+
# deliver; see serve() for why a second turn gets a new session.
|
|
501
|
+
self.replies = 0
|
|
502
|
+
# Set when a call into the model raised, which makes this session spent
|
|
503
|
+
# whether or not it ever answered. fail_turn owns it.
|
|
504
|
+
self.failed = False
|
|
505
|
+
# Set while close() is deliberately tearing this session down, so the
|
|
506
|
+
# reader can tell a stream that went away because we ended it from one
|
|
507
|
+
# that went away on its own.
|
|
508
|
+
self.closing = False
|
|
509
|
+
# Which tools ran, in order. The handover boundary is the whole point of
|
|
510
|
+
# this relay, so "it called hand_over_to_firstmate and did not answer
|
|
511
|
+
# for firstmate" has to be evidence in the run record, not an inference
|
|
512
|
+
# from a count.
|
|
513
|
+
self.tool_names = []
|
|
514
|
+
self.ended = asyncio.Event()
|
|
515
|
+
self.turn_done = asyncio.Event()
|
|
516
|
+
self.home = options.home or records.default_home()
|
|
517
|
+
self.scope = options.scope or records.read_scope(self.home)
|
|
518
|
+
self.root = os.path.dirname(os.path.abspath(__file__))
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------- protocol
|
|
521
|
+
|
|
522
|
+
def _event(self, obj):
|
|
523
|
+
from aws_sdk_bedrock_runtime.models import (
|
|
524
|
+
BidirectionalInputPayloadPart,
|
|
525
|
+
InvokeModelWithBidirectionalStreamInputChunk)
|
|
526
|
+
return InvokeModelWithBidirectionalStreamInputChunk(
|
|
527
|
+
value=BidirectionalInputPayloadPart(
|
|
528
|
+
bytes_=json.dumps({"event": obj}).encode()))
|
|
529
|
+
|
|
530
|
+
async def _send(self, obj):
|
|
531
|
+
await self.stream.input_stream.send(self._event(obj))
|
|
532
|
+
|
|
533
|
+
async def start(self):
|
|
534
|
+
from aws_sdk_bedrock_runtime.client import (
|
|
535
|
+
AsyncBedrockRuntimeClient,
|
|
536
|
+
InvokeModelWithBidirectionalStreamOperationInput)
|
|
537
|
+
from aws_sdk_bedrock_runtime.config import AsyncBedrockRuntimeConfig
|
|
538
|
+
|
|
539
|
+
creds = await self.credentials.get()
|
|
540
|
+
began = time.monotonic()
|
|
541
|
+
config = await AsyncBedrockRuntimeConfig.resolve(
|
|
542
|
+
endpoint_uri="https://bedrock-runtime.{}.amazonaws.com".format(
|
|
543
|
+
self.options.region),
|
|
544
|
+
region=self.options.region, **creds)
|
|
545
|
+
client = AsyncBedrockRuntimeClient(config=config)
|
|
546
|
+
self.stream = await client.invoke_model_with_bidirectional_stream(
|
|
547
|
+
InvokeModelWithBidirectionalStreamOperationInput(
|
|
548
|
+
model_id=self.options.model))
|
|
549
|
+
self.connect_seconds = round(time.monotonic() - began, 3)
|
|
550
|
+
self.reader_task = asyncio.create_task(self._read_model())
|
|
551
|
+
|
|
552
|
+
await self._send({"sessionStart": {"inferenceConfiguration": {
|
|
553
|
+
"maxTokens": 512, "topP": 0.9, "temperature": 0.7}}})
|
|
554
|
+
await self._send({"promptStart": {
|
|
555
|
+
"promptName": self.prompt,
|
|
556
|
+
"textOutputConfiguration": {"mediaType": "text/plain"},
|
|
557
|
+
"audioOutputConfiguration": {
|
|
558
|
+
"mediaType": "audio/lpcm", "sampleRateHertz": OUT_RATE,
|
|
559
|
+
"sampleSizeBits": 16, "channelCount": 1,
|
|
560
|
+
"voiceId": self.options.voice, "encoding": "base64",
|
|
561
|
+
"audioType": "SPEECH"},
|
|
562
|
+
"toolUseOutputConfiguration": {"mediaType": "application/json"},
|
|
563
|
+
"toolConfiguration": TOOLS}})
|
|
564
|
+
content = str(uuid.uuid4())
|
|
565
|
+
await self._send({"contentStart": {
|
|
566
|
+
"promptName": self.prompt, "contentName": content, "type": "TEXT",
|
|
567
|
+
"interactive": True, "role": "SYSTEM",
|
|
568
|
+
"textInputConfiguration": {"mediaType": "text/plain"}}})
|
|
569
|
+
await self._send({"textInput": {
|
|
570
|
+
"promptName": self.prompt, "contentName": content,
|
|
571
|
+
"content": SYSTEM_PROMPT}})
|
|
572
|
+
await self._send({"contentEnd": {
|
|
573
|
+
"promptName": self.prompt, "contentName": content}})
|
|
574
|
+
log(self.verbose, "session up in {}s, read scope {}".format(
|
|
575
|
+
self.connect_seconds, self.scope))
|
|
576
|
+
|
|
577
|
+
async def close(self):
|
|
578
|
+
self.closing = True
|
|
579
|
+
if self.stream is None:
|
|
580
|
+
return
|
|
581
|
+
try:
|
|
582
|
+
if self.audio_content:
|
|
583
|
+
await self._send({"contentEnd": {
|
|
584
|
+
"promptName": self.prompt, "contentName": self.audio_content}})
|
|
585
|
+
self.audio_content = None
|
|
586
|
+
await self._send({"promptEnd": {"promptName": self.prompt}})
|
|
587
|
+
await self._send({"sessionEnd": {}})
|
|
588
|
+
await self.stream.input_stream.close()
|
|
589
|
+
except Exception as exc: # noqa: BLE001
|
|
590
|
+
log(self.verbose, "close: {}: {}".format(type(exc).__name__, exc))
|
|
591
|
+
if self.reader_task is not None:
|
|
592
|
+
try:
|
|
593
|
+
# gather collects a reader that died on its own instead of
|
|
594
|
+
# re-raising it here, the same way the sends above are absorbed.
|
|
595
|
+
# Awaiting a failed task raises on EVERY await, and close() is
|
|
596
|
+
# the first statement of renew and of serve's finally, so a
|
|
597
|
+
# close that re-raises is the difference between one failed turn
|
|
598
|
+
# and a relay that can never build another session or even say
|
|
599
|
+
# goodbye to the client.
|
|
600
|
+
await asyncio.wait_for(
|
|
601
|
+
asyncio.gather(self.reader_task, return_exceptions=True),
|
|
602
|
+
timeout=10)
|
|
603
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
# ------------------------------------------------------------------ uplink
|
|
607
|
+
|
|
608
|
+
async def talk_start(self):
|
|
609
|
+
"""Open an audio block for a new turn, if one is not already open."""
|
|
610
|
+
if self.audio_content is not None:
|
|
611
|
+
return
|
|
612
|
+
self.audio_content = str(uuid.uuid4())
|
|
613
|
+
self.turn = {"began": time.monotonic()}
|
|
614
|
+
self.tool_calls = 0
|
|
615
|
+
self.tool_names = []
|
|
616
|
+
self.turn_done.clear()
|
|
617
|
+
self.down.arm_turn()
|
|
618
|
+
await self._send({"contentStart": {
|
|
619
|
+
"promptName": self.prompt, "contentName": self.audio_content,
|
|
620
|
+
"type": "AUDIO", "interactive": True, "role": "USER",
|
|
621
|
+
"audioInputConfiguration": {
|
|
622
|
+
"mediaType": "audio/lpcm", "sampleRateHertz": IN_RATE,
|
|
623
|
+
"sampleSizeBits": 16, "channelCount": 1,
|
|
624
|
+
"audioType": "SPEECH", "encoding": "base64"}}})
|
|
625
|
+
log(self.verbose, "talk start")
|
|
626
|
+
|
|
627
|
+
async def audio(self, pcm):
|
|
628
|
+
"""Forward captured audio, chunked the way the measurements were taken.
|
|
629
|
+
|
|
630
|
+
Audio with no turn open is dropped rather than opening one. Both listen
|
|
631
|
+
modes send a talk start before any audio, so this never fires in ordinary
|
|
632
|
+
use, but the capture callback races the key release: a chunk already past
|
|
633
|
+
the gate check can reach the relay behind the talk end. Opening a block
|
|
634
|
+
for it would append the captain's stray tenth of a second to a session
|
|
635
|
+
that is already generating its reply, which is the unconditional barge-in
|
|
636
|
+
the per-turn reconnect exists to avoid, and it would leave that block open
|
|
637
|
+
so the next turn skipped its own reset and its first-audio mark.
|
|
638
|
+
"""
|
|
639
|
+
if self.audio_content is None:
|
|
640
|
+
log(self.verbose, "dropping {} bytes of audio that arrived with no "
|
|
641
|
+
"turn open".format(len(pcm)))
|
|
642
|
+
return
|
|
643
|
+
for at in range(0, len(pcm), CHUNK):
|
|
644
|
+
await self._send({"audioInput": {
|
|
645
|
+
"promptName": self.prompt, "contentName": self.audio_content,
|
|
646
|
+
"content": base64.b64encode(pcm[at:at + CHUNK]).decode()}})
|
|
647
|
+
|
|
648
|
+
async def talk_end(self):
|
|
649
|
+
"""Close the turn: pad with silence, then close the audio block.
|
|
650
|
+
|
|
651
|
+
The padding is trap 2: a clip with no trailing silence is truncated and
|
|
652
|
+
never answered. It is a CONTENT requirement rather than a time one. The
|
|
653
|
+
padding is sent unpaced, so measured against tail_ms 200 through 800 it
|
|
654
|
+
cost no wall clock at all; what it buys is the model deciding the
|
|
655
|
+
captain has stopped. 400 ms is therefore free margin above the 200 ms
|
|
656
|
+
floor where answers first appear.
|
|
657
|
+
|
|
658
|
+
The clock is still taken before the padding, because that instant is
|
|
659
|
+
when the captain actually stopped talking and every number this build
|
|
660
|
+
reports has to be measured from there.
|
|
661
|
+
"""
|
|
662
|
+
if self.audio_content is None:
|
|
663
|
+
return
|
|
664
|
+
self.turn["talk_end"] = time.monotonic()
|
|
665
|
+
tail = self.options.tail_ms * BYTES_PER_MS_IN
|
|
666
|
+
if tail:
|
|
667
|
+
await self.audio(b"\x00" * tail)
|
|
668
|
+
await self._send({"contentEnd": {
|
|
669
|
+
"promptName": self.prompt, "contentName": self.audio_content}})
|
|
670
|
+
self.audio_content = None
|
|
671
|
+
log(self.verbose, "talk end, {} ms of silence appended".format(
|
|
672
|
+
self.options.tail_ms))
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------- downlink
|
|
675
|
+
|
|
676
|
+
def _mark(self, name, at=None):
|
|
677
|
+
now = at if at is not None else time.monotonic()
|
|
678
|
+
self.turn.setdefault(name, now)
|
|
679
|
+
base = self.turn.get("talk_end")
|
|
680
|
+
if base is None:
|
|
681
|
+
return
|
|
682
|
+
self.down.send_json(frame.MARK, {
|
|
683
|
+
"mark": name,
|
|
684
|
+
"since_talk_end": round(now - base, 3),
|
|
685
|
+
"tool_calls": self.tool_calls,
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
# Every question worth asking about a session is a question about the order
|
|
689
|
+
# of these events and the stop reason on them, so --verbose prints that
|
|
690
|
+
# order. audioOutput and usageEvent are left out because they repeat many
|
|
691
|
+
# times per reply and bury everything else.
|
|
692
|
+
TRACE_SKIP = ("audioOutput", "usageEvent")
|
|
693
|
+
|
|
694
|
+
def _trace(self, event):
|
|
695
|
+
for name, body in event.items():
|
|
696
|
+
if name in self.TRACE_SKIP:
|
|
697
|
+
continue
|
|
698
|
+
detail = ""
|
|
699
|
+
if isinstance(body, dict):
|
|
700
|
+
bits = [(k, body.get(k)) for k in ("type", "role", "stopReason")
|
|
701
|
+
if body.get(k)]
|
|
702
|
+
detail = "".join(" {}={}".format(k, v) for k, v in bits)
|
|
703
|
+
log(True, "event {}{}".format(name, detail))
|
|
704
|
+
|
|
705
|
+
async def _read_model(self):
|
|
706
|
+
"""Read the model's events until the stream ends or fails, and report which.
|
|
707
|
+
|
|
708
|
+
Handling an event reaches back into the model, to answer a tool call, so
|
|
709
|
+
it can fail on its own rather than only the read can. Either way this
|
|
710
|
+
session is finished, and the finally below is the one thing that must
|
|
711
|
+
still happen: --self-test waits on turn_done for the length of a turn,
|
|
712
|
+
and the next talk key reads ended to decide whether this session can
|
|
713
|
+
still be used. Leaving them clear is what turned one dropped stream into
|
|
714
|
+
a relay that never answered again.
|
|
715
|
+
|
|
716
|
+
The two ways out are not the same event and are not reported the same
|
|
717
|
+
way. A stream that simply ends is the end of a session and nothing more,
|
|
718
|
+
so it is named as that and not as a failure. A stream that raises, here
|
|
719
|
+
or under an event handler, is this turn failing, so it goes through
|
|
720
|
+
fail_turn and reaches the captain.
|
|
721
|
+
|
|
722
|
+
Either way the client is told, once, because either way it is waiting on
|
|
723
|
+
a turn that is not coming and a notice is the only thing that releases it.
|
|
724
|
+
The end is announced HERE rather than from the serve loop because this is
|
|
725
|
+
the one moment it happens: the flag it sets stays set for every later
|
|
726
|
+
frame of the same key press, so a loop that announced it would say it ten
|
|
727
|
+
times a second while the captain was still speaking.
|
|
728
|
+
|
|
729
|
+
Neither is a stream that went away because close() asked it to: renew
|
|
730
|
+
closes the old session on every single turn, so announcing that would put
|
|
731
|
+
a failure notice in front of the captain on every ordinary turn.
|
|
732
|
+
"""
|
|
733
|
+
broke = None
|
|
734
|
+
try:
|
|
735
|
+
while True:
|
|
736
|
+
try:
|
|
737
|
+
out = await self.stream.await_output()
|
|
738
|
+
result = await out[1].receive()
|
|
739
|
+
except Exception as exc: # noqa: BLE001
|
|
740
|
+
log(self.verbose, "model stream dropped: {}: {}".format(
|
|
741
|
+
type(exc).__name__, exc))
|
|
742
|
+
broke = exc
|
|
743
|
+
break
|
|
744
|
+
if result is None:
|
|
745
|
+
break
|
|
746
|
+
raw = result.value.bytes_
|
|
747
|
+
if not raw:
|
|
748
|
+
continue
|
|
749
|
+
try:
|
|
750
|
+
event = json.loads(raw.decode()).get("event", {})
|
|
751
|
+
except ValueError:
|
|
752
|
+
continue
|
|
753
|
+
try:
|
|
754
|
+
await self._handle(event)
|
|
755
|
+
except Exception as exc: # noqa: BLE001
|
|
756
|
+
log(self.verbose, "handling {} failed: {}: {}".format(
|
|
757
|
+
", ".join(event) or "an event", type(exc).__name__, exc))
|
|
758
|
+
broke = exc
|
|
759
|
+
break
|
|
760
|
+
finally:
|
|
761
|
+
# Neither is said when the uplink has already named this turn: the
|
|
762
|
+
# frame that broke the model usually breaks the reader an instant
|
|
763
|
+
# later, and the captain hears about one turn once.
|
|
764
|
+
if not self.closing and not self.failed:
|
|
765
|
+
if broke is not None:
|
|
766
|
+
fail_turn(self, self.down, broke)
|
|
767
|
+
else:
|
|
768
|
+
self.down.send_json(
|
|
769
|
+
frame.NOTICE, {"event": "session-ended"})
|
|
770
|
+
self.ended.set()
|
|
771
|
+
self.turn_done.set()
|
|
772
|
+
|
|
773
|
+
async def _handle(self, event):
|
|
774
|
+
if self.verbose:
|
|
775
|
+
self._trace(event)
|
|
776
|
+
|
|
777
|
+
if "userSpeechEnd" in event:
|
|
778
|
+
# Open microphone: the model's own detector, not a talk-end frame,
|
|
779
|
+
# is what ends the turn, so the clock starts here instead.
|
|
780
|
+
self.turn.setdefault("talk_end", time.monotonic())
|
|
781
|
+
log(self.verbose, "model reports the captain stopped speaking")
|
|
782
|
+
|
|
783
|
+
if "audioOutput" in event:
|
|
784
|
+
pcm = base64.b64decode(event["audioOutput"].get("content", ""))
|
|
785
|
+
if pcm:
|
|
786
|
+
if "first_audio" not in self.turn:
|
|
787
|
+
self._mark("first_audio")
|
|
788
|
+
self.down.send(frame.AUDIO, pcm)
|
|
789
|
+
|
|
790
|
+
if "textOutput" in event:
|
|
791
|
+
text = event["textOutput"].get("content", "")
|
|
792
|
+
role = event["textOutput"].get("role", "")
|
|
793
|
+
if text:
|
|
794
|
+
self.down.send_json(frame.TEXT, {"role": role, "text": text})
|
|
795
|
+
log(self.verbose, "{}: {}".format(role.lower(), text[:120]))
|
|
796
|
+
if '"interrupted"' in text and "true" in text:
|
|
797
|
+
# Informational only. Stopping playback mid-sentence is
|
|
798
|
+
# barge-in, which is step three of the design, not this build.
|
|
799
|
+
self.down.send_json(frame.NOTICE, {"event": "interrupted"})
|
|
800
|
+
|
|
801
|
+
if "toolUse" in event:
|
|
802
|
+
self._mark("tool_use")
|
|
803
|
+
self.tool_calls += 1
|
|
804
|
+
self.tool_names.append(event["toolUse"].get("toolName", ""))
|
|
805
|
+
await self._run_tool(event["toolUse"])
|
|
806
|
+
|
|
807
|
+
if "contentEnd" in event:
|
|
808
|
+
stop = event["contentEnd"].get("stopReason")
|
|
809
|
+
if stop == "INTERRUPTED":
|
|
810
|
+
self.down.send_json(frame.NOTICE, {"event": "interrupted"})
|
|
811
|
+
if stop == "END_TURN":
|
|
812
|
+
# Trap 1: this, not completionEnd, is the end of the reply.
|
|
813
|
+
self._mark("reply_end")
|
|
814
|
+
# first_audio above is stamped when the model event is decoded.
|
|
815
|
+
# The Downlink knows the later instant when that audio reached
|
|
816
|
+
# the connection, which is the one the captain hears, so it is
|
|
817
|
+
# reported too rather than measured and thrown away. It can only
|
|
818
|
+
# be read once the frame is out, hence here and not there.
|
|
819
|
+
wire = self.down.first_audio()
|
|
820
|
+
if wire is not None:
|
|
821
|
+
self._mark("first_audio_wire", wire)
|
|
822
|
+
self.replies += 1
|
|
823
|
+
self.turn_done.set()
|
|
824
|
+
|
|
825
|
+
# -------------------------------------------------------------------- tools
|
|
826
|
+
|
|
827
|
+
async def _run_tool(self, call):
|
|
828
|
+
name = call.get("toolName", "")
|
|
829
|
+
use_id = call.get("toolUseId")
|
|
830
|
+
raw = call.get("content") or "{}"
|
|
831
|
+
try:
|
|
832
|
+
arguments = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
833
|
+
except ValueError:
|
|
834
|
+
arguments = {}
|
|
835
|
+
log(self.verbose, "tool {} {}".format(name, arguments))
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
if name == "get_fleet_status":
|
|
839
|
+
# Off the loop like the handover below it: the model is told to
|
|
840
|
+
# call this on every question, and its directory and file reads
|
|
841
|
+
# would otherwise stop the relay reading the captain's audio.
|
|
842
|
+
result = await asyncio.to_thread(
|
|
843
|
+
records.fleet_status, self.home, self.scope)
|
|
844
|
+
elif name == "hand_over_to_firstmate":
|
|
845
|
+
request = (arguments.get("request") or "").strip()
|
|
846
|
+
result = await asyncio.to_thread(
|
|
847
|
+
records.queue_request, request, self.home, self.root)
|
|
848
|
+
self.down.send_json(frame.NOTICE, {
|
|
849
|
+
"event": "queued", "request": request,
|
|
850
|
+
"note_id": result.get("note_id", "")})
|
|
851
|
+
else:
|
|
852
|
+
result = {"error": "no such tool: {}".format(name)}
|
|
853
|
+
except records.RecordError as exc:
|
|
854
|
+
result = {"error": str(exc)}
|
|
855
|
+
except Exception as exc: # noqa: BLE001
|
|
856
|
+
result = {"error": "{}: {}".format(type(exc).__name__, exc)}
|
|
857
|
+
|
|
858
|
+
content = str(uuid.uuid4())
|
|
859
|
+
await self._send({"contentStart": {
|
|
860
|
+
"promptName": self.prompt, "contentName": content, "type": "TOOL",
|
|
861
|
+
"interactive": False, "role": "TOOL",
|
|
862
|
+
"toolResultInputConfiguration": {
|
|
863
|
+
"toolUseId": use_id, "type": "TEXT",
|
|
864
|
+
"textInputConfiguration": {"mediaType": "text/plain"}}}})
|
|
865
|
+
await self._send({"toolResult": {
|
|
866
|
+
"promptName": self.prompt, "contentName": content,
|
|
867
|
+
"content": json.dumps(result)}})
|
|
868
|
+
await self._send({"contentEnd": {
|
|
869
|
+
"promptName": self.prompt, "contentName": content}})
|
|
870
|
+
self._mark("tool_answered")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def fail_turn(session, down, exc):
|
|
874
|
+
"""Mark a session spent and name this turn's failure to the client.
|
|
875
|
+
|
|
876
|
+
One place, because both ends of the relay can break a turn and the captain
|
|
877
|
+
should not be able to tell which by whether they heard anything. Every part
|
|
878
|
+
of it is for a different reader. The mark is what the next talk key reads to
|
|
879
|
+
build a replacement instead of talking into a session that is already gone.
|
|
880
|
+
The notice is what the captain gets, and it is the only thing that releases a
|
|
881
|
+
client waiting for a reply, so a failure that is merely marked costs them
|
|
882
|
+
their whole timeout and leaves a record saying the turn went unanswered
|
|
883
|
+
without saying why. The reason on the turn is for --self-test, which has no
|
|
884
|
+
client to notice anything.
|
|
885
|
+
"""
|
|
886
|
+
reason = "{}: {}".format(type(exc).__name__, exc)
|
|
887
|
+
session.failed = True
|
|
888
|
+
session.turn["failed"] = reason
|
|
889
|
+
down.send_json(frame.NOTICE, {"event": "turn-failed", "error": reason})
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
async def renew(session, options, down):
|
|
893
|
+
"""Replace a session that has already answered once, and return the new one.
|
|
894
|
+
|
|
895
|
+
MEASURED, and the reason this exists: a second user audio block in a session
|
|
896
|
+
that has already spoken is treated as barge-in, unconditionally. The model
|
|
897
|
+
raises INTERRUPTED the instant the block opens, and waiting does not help.
|
|
898
|
+
Six consecutive turns were tried with no wait, with a wait until the reply's
|
|
899
|
+
audio had all arrived, and with a wait of the reply's full spoken duration
|
|
900
|
+
after that; every one of those interrupted every second turn. Worse, an
|
|
901
|
+
interrupted turn that calls a tool is then lost outright: the model asks for
|
|
902
|
+
the tool, takes the result, and never answers.
|
|
903
|
+
|
|
904
|
+
Reconnecting instead costs 0.02 seconds, measured, and it happens when the
|
|
905
|
+
captain presses the talk key rather than while they are waiting for a reply,
|
|
906
|
+
so it is invisible. What it gives up is conversational memory: each turn
|
|
907
|
+
starts fresh, so the captain cannot say "and what about that one". Carrying
|
|
908
|
+
context across turns means handling barge-in properly, which is step three of
|
|
909
|
+
the design, not this build. It also means the system prompt is sent once per
|
|
910
|
+
turn rather than once per session, which is the small cost of the trade.
|
|
911
|
+
"""
|
|
912
|
+
log(options.verbose, "renewing the session for a new turn")
|
|
913
|
+
await session.close()
|
|
914
|
+
fresh = Session(options, down, session.credentials)
|
|
915
|
+
try:
|
|
916
|
+
await fresh.start()
|
|
917
|
+
except BaseException:
|
|
918
|
+
# start() creates the reader task before it sends anything, so a
|
|
919
|
+
# reconnect that fails part way leaves a live task holding an open
|
|
920
|
+
# bidirectional stream. Nothing would ever close it, and it would keep
|
|
921
|
+
# writing into the shared Downlink, so each retry would strand one more.
|
|
922
|
+
await fresh.close()
|
|
923
|
+
raise
|
|
924
|
+
down.send_json(frame.NOTICE, {
|
|
925
|
+
"event": "renewed", "connect_seconds": fresh.connect_seconds})
|
|
926
|
+
return fresh
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
async def read_uplink_frame(reader):
|
|
930
|
+
"""Return the next (kind, payload) the client sent, or raise on a bad header.
|
|
931
|
+
|
|
932
|
+
The header is checked before the payload is read, not after. A
|
|
933
|
+
desynchronised uplink offers a length of up to 4 GiB, and waiting for that
|
|
934
|
+
many bytes is a hang where the wire format promises a loud error, with the
|
|
935
|
+
captain sitting in front of a client that will never answer.
|
|
936
|
+
"""
|
|
937
|
+
head = await reader.readexactly(frame.HEADER.size)
|
|
938
|
+
kind, length = frame.HEADER.unpack(head)
|
|
939
|
+
frame.check_header(kind, length)
|
|
940
|
+
payload = await reader.readexactly(length) if length else b""
|
|
941
|
+
return kind, payload
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
async def handle_uplink_frame(kind, payload, session, options, down):
|
|
945
|
+
"""Act on one frame from the client. Returns (session to use next, keep serving).
|
|
946
|
+
|
|
947
|
+
Every branch below reaches the model, and the model side fails on its own:
|
|
948
|
+
a reconnect can be throttled, a token can expire between turns, a stream can
|
|
949
|
+
drop. Because the relay rebuilds the session on every turn by design, one
|
|
950
|
+
such failure would otherwise leave the loop, end the relay with a traceback
|
|
951
|
+
on the stderr the client inherits, and cost the captain a whole session for
|
|
952
|
+
a single bad reconnect. Instead it is named in a notice and the session is
|
|
953
|
+
marked spent, so the next press of the talk key builds a new one and tries
|
|
954
|
+
again. A failure the model cannot recover from is named once per turn, which
|
|
955
|
+
is a captain who can hear what is wrong rather than a dead pipe.
|
|
956
|
+
|
|
957
|
+
Once per TURN and not once per frame: the captain is still holding the talk
|
|
958
|
+
key when the failure lands, and the rest of that key press is another thirty
|
|
959
|
+
audio frames a second apart in tenths. Reporting each one would put ten
|
|
960
|
+
identical lines a second in front of the captain and keep calling into a
|
|
961
|
+
session that is already gone, so the remainder of a failed turn is dropped
|
|
962
|
+
where it arrives.
|
|
963
|
+
"""
|
|
964
|
+
if kind == frame.QUIT:
|
|
965
|
+
return session, False
|
|
966
|
+
if session.failed and kind != frame.TALK_START:
|
|
967
|
+
return session, True
|
|
968
|
+
try:
|
|
969
|
+
if kind == frame.TALK_START:
|
|
970
|
+
if session.failed or session.replies or session.ended.is_set():
|
|
971
|
+
session = await renew(session, options, down)
|
|
972
|
+
await session.talk_start()
|
|
973
|
+
elif kind == frame.AUDIO:
|
|
974
|
+
await session.audio(payload)
|
|
975
|
+
elif kind == frame.TALK_END:
|
|
976
|
+
await session.talk_end()
|
|
977
|
+
else:
|
|
978
|
+
log(options.verbose, "ignoring uplink kind {!r}".format(kind))
|
|
979
|
+
except Exception as exc: # noqa: BLE001
|
|
980
|
+
log(options.verbose, "turn failed: {}: {}".format(
|
|
981
|
+
type(exc).__name__, exc))
|
|
982
|
+
fail_turn(session, down, exc)
|
|
983
|
+
return session, True
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
async def serve(options):
|
|
987
|
+
"""Relay frames between the client on stdin/stdout and the model sessions behind it.
|
|
988
|
+
|
|
989
|
+
Three things end this, and nothing else does: the client's QUIT frame, the
|
|
990
|
+
client closing the connection, and an uplink that has stopped being a frame
|
|
991
|
+
stream. In particular a model session ending is not one of them. It happens
|
|
992
|
+
on its own, mid-conversation, and the next talk key builds a replacement
|
|
993
|
+
through the same path every ordinary turn already uses, at a measured cost of
|
|
994
|
+
0.02 s. A renew that cannot be made is spoken to the captain by fail_turn, so
|
|
995
|
+
the loud failure is the one they get; ending the relay here would instead
|
|
996
|
+
leave them speaking a whole question into nothing.
|
|
997
|
+
"""
|
|
998
|
+
loop = asyncio.get_running_loop()
|
|
999
|
+
reader = asyncio.StreamReader()
|
|
1000
|
+
await loop.connect_read_pipe(
|
|
1001
|
+
lambda: asyncio.StreamReaderProtocol(reader), sys.stdin.buffer)
|
|
1002
|
+
# Ahead of every frame, so a login shell that prints a banner on stdout
|
|
1003
|
+
# cannot desynchronise the client. See fm_voice_frame.MAGIC.
|
|
1004
|
+
sys.stdout.buffer.write(frame.MAGIC)
|
|
1005
|
+
sys.stdout.buffer.flush()
|
|
1006
|
+
down = Downlink(sys.stdout.buffer)
|
|
1007
|
+
session = Session(options, down, Credentials(options.profile, options.verbose))
|
|
1008
|
+
await session.start()
|
|
1009
|
+
down.send_json(frame.NOTICE, {
|
|
1010
|
+
"event": "ready", "model": options.model, "region": options.region,
|
|
1011
|
+
"read_scope": session.scope, "tail_ms": options.tail_ms,
|
|
1012
|
+
"connect_seconds": session.connect_seconds})
|
|
1013
|
+
|
|
1014
|
+
status = 0
|
|
1015
|
+
# A fault the client cannot see for itself, held so the teardown can name it
|
|
1016
|
+
# down the connection as well as on this stderr. Nothing is captured on the
|
|
1017
|
+
# branch above it: there the client is the end that went away, and there is
|
|
1018
|
+
# nobody left to tell.
|
|
1019
|
+
reason = None
|
|
1020
|
+
try:
|
|
1021
|
+
while True:
|
|
1022
|
+
kind, payload = await read_uplink_frame(reader)
|
|
1023
|
+
session, serving = await handle_uplink_frame(
|
|
1024
|
+
kind, payload, session, options, down)
|
|
1025
|
+
if not serving:
|
|
1026
|
+
break
|
|
1027
|
+
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
1028
|
+
log(options.verbose, "client closed the connection")
|
|
1029
|
+
except frame.FrameError as exc:
|
|
1030
|
+
sys.stderr.write(
|
|
1031
|
+
"fm-voice-relay: the uplink is not a frame stream any more: {}\n"
|
|
1032
|
+
.format(exc))
|
|
1033
|
+
reason = "{}: {}".format(type(exc).__name__, exc)
|
|
1034
|
+
status = 2
|
|
1035
|
+
finally:
|
|
1036
|
+
# On fail_turn's shape and before close(), which awaits the model stream
|
|
1037
|
+
# and can be slow or raise. session.close() also sets closing, which
|
|
1038
|
+
# silences the reader's own notice, so a goodbye on its own would leave
|
|
1039
|
+
# the captain's turn record saying only that the turn went unanswered
|
|
1040
|
+
# while the reason for it sat on a stderr no run file quotes.
|
|
1041
|
+
if reason is not None:
|
|
1042
|
+
down.send_json(frame.NOTICE, {"event": "turn-failed",
|
|
1043
|
+
"error": reason})
|
|
1044
|
+
await session.close()
|
|
1045
|
+
down.send(frame.BYE)
|
|
1046
|
+
down.close()
|
|
1047
|
+
return status
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def self_test(options):
|
|
1051
|
+
"""Feed one PCM file through a real session and report the timings."""
|
|
1052
|
+
with open(options.self_test, "rb") as handle:
|
|
1053
|
+
pcm = handle.read()
|
|
1054
|
+
|
|
1055
|
+
class Sink:
|
|
1056
|
+
"""Stands in for the client, counting reply audio and timing its arrival.
|
|
1057
|
+
|
|
1058
|
+
There is no connection here and no writer thread: this stamps its arrival
|
|
1059
|
+
inline, in the same coroutine that decoded the model event. So the wire
|
|
1060
|
+
hand-off Downlink times on the --serve path does not exist in this mode,
|
|
1061
|
+
and the record below reports no figure for it rather than reporting one
|
|
1062
|
+
that would be zero because of how this stub is built. The first_audio
|
|
1063
|
+
figure it does report is the model event, which is real in both modes.
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
def __init__(self):
|
|
1067
|
+
self.first = None
|
|
1068
|
+
self.bytes = 0
|
|
1069
|
+
self.heard = []
|
|
1070
|
+
self.said = []
|
|
1071
|
+
self.notices = []
|
|
1072
|
+
|
|
1073
|
+
def send(self, kind, payload=b""):
|
|
1074
|
+
if kind == frame.AUDIO:
|
|
1075
|
+
if self.first is None:
|
|
1076
|
+
self.first = time.monotonic()
|
|
1077
|
+
self.bytes += len(payload)
|
|
1078
|
+
|
|
1079
|
+
def send_json(self, kind, obj):
|
|
1080
|
+
# The transcript is the only way to check the two things that matter
|
|
1081
|
+
# about a spoken answer: that the words were heard correctly, and
|
|
1082
|
+
# that the agent handed real work over instead of claiming it.
|
|
1083
|
+
if kind == frame.TEXT:
|
|
1084
|
+
text = (obj.get("text") or "").strip()
|
|
1085
|
+
if not text or text.startswith("{"):
|
|
1086
|
+
return
|
|
1087
|
+
if obj.get("role") == "USER":
|
|
1088
|
+
self.heard.append(text)
|
|
1089
|
+
elif obj.get("role") == "ASSISTANT":
|
|
1090
|
+
self.said.append(text)
|
|
1091
|
+
elif kind == frame.NOTICE:
|
|
1092
|
+
self.notices.append(obj.get("event", ""))
|
|
1093
|
+
|
|
1094
|
+
def arm_turn(self):
|
|
1095
|
+
self.first = None
|
|
1096
|
+
|
|
1097
|
+
def first_audio(self):
|
|
1098
|
+
return self.first
|
|
1099
|
+
|
|
1100
|
+
sink = Sink()
|
|
1101
|
+
session = Session(options, sink, Credentials(options.profile, options.verbose))
|
|
1102
|
+
await session.start()
|
|
1103
|
+
await session.talk_start()
|
|
1104
|
+
# Paced at real time, because a file pushed as fast as the socket accepts it
|
|
1105
|
+
# would measure the socket rather than the conversation.
|
|
1106
|
+
for at in range(0, len(pcm), CHUNK):
|
|
1107
|
+
await session.audio(pcm[at:at + CHUNK])
|
|
1108
|
+
await asyncio.sleep(CHUNK / (IN_RATE * 2.0))
|
|
1109
|
+
await session.talk_end()
|
|
1110
|
+
try:
|
|
1111
|
+
await asyncio.wait_for(session.turn_done.wait(),
|
|
1112
|
+
timeout=options.turn_timeout)
|
|
1113
|
+
except asyncio.TimeoutError:
|
|
1114
|
+
session.turn["timeout"] = True
|
|
1115
|
+
await session.close()
|
|
1116
|
+
|
|
1117
|
+
base = session.turn.get("talk_end")
|
|
1118
|
+
|
|
1119
|
+
def since(name):
|
|
1120
|
+
at = session.turn.get(name)
|
|
1121
|
+
if at is None or base is None:
|
|
1122
|
+
return None
|
|
1123
|
+
return round(at - base, 3)
|
|
1124
|
+
|
|
1125
|
+
# A negative figure means the model started answering before this end of the
|
|
1126
|
+
# stream said the turn was over, which happens when the clip handed in
|
|
1127
|
+
# ALREADY ends in silence: the model's own endpoint detector fires part way
|
|
1128
|
+
# through that silence while the file is still being streamed at real time.
|
|
1129
|
+
# The reply is genuinely fast in that case but the number is meaningless,
|
|
1130
|
+
# because it is measured from the wrong instant. Feed --self-test a clip that
|
|
1131
|
+
# ends on speech and let --tail-ms add the silence. This is flagged rather
|
|
1132
|
+
# than silently recorded, because a negative in a results file gets averaged
|
|
1133
|
+
# into a report by someone who was not here.
|
|
1134
|
+
early = [n for n in ("tool_use", "first_audio", "reply_end")
|
|
1135
|
+
if (since(n) or 0) < 0]
|
|
1136
|
+
if early:
|
|
1137
|
+
sys.stderr.write(
|
|
1138
|
+
"fm-voice-relay: {} came in before the end of the clip, so these "
|
|
1139
|
+
"timings are measured from the wrong instant. The clip already ends "
|
|
1140
|
+
"in silence; pass one that ends on speech and use --tail-ms.\n"
|
|
1141
|
+
.format(", ".join(early)))
|
|
1142
|
+
|
|
1143
|
+
print(json.dumps({
|
|
1144
|
+
"mode": "self-test",
|
|
1145
|
+
"model": options.model,
|
|
1146
|
+
"region": options.region,
|
|
1147
|
+
"read_scope": session.scope,
|
|
1148
|
+
"input_seconds": round(len(pcm) / float(IN_RATE * 2), 3),
|
|
1149
|
+
"tail_ms": options.tail_ms,
|
|
1150
|
+
"connect_seconds": session.connect_seconds,
|
|
1151
|
+
"tool_calls": session.tool_calls,
|
|
1152
|
+
"tool_names": session.tool_names,
|
|
1153
|
+
"tool_use_s": since("tool_use"),
|
|
1154
|
+
"first_audio_s": since("first_audio"),
|
|
1155
|
+
"reply_end_s": since("reply_end"),
|
|
1156
|
+
"reply_audio_seconds": round(sink.bytes / float(OUT_RATE * 2), 3),
|
|
1157
|
+
"answered": sink.bytes > 0,
|
|
1158
|
+
"timed_out": bool(session.turn.get("timeout")),
|
|
1159
|
+
# Named the same as the client's turn record, and here for the same
|
|
1160
|
+
# reason: a record that says only that the turn was not answered invites
|
|
1161
|
+
# someone who was not here to average an infrastructure failure into a
|
|
1162
|
+
# latency figure.
|
|
1163
|
+
"relay_error": session.turn.get("failed"),
|
|
1164
|
+
"clock_unusable": early,
|
|
1165
|
+
"heard": " ".join(sink.heard),
|
|
1166
|
+
"said": " ".join(sink.said),
|
|
1167
|
+
"notices": sink.notices,
|
|
1168
|
+
}))
|
|
1169
|
+
return 0 if sink.bytes > 0 else 1
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def parse_args(argv):
|
|
1173
|
+
parser = argparse.ArgumentParser(
|
|
1174
|
+
prog="fm-voice-relay.py", add_help=True,
|
|
1175
|
+
description=__doc__.splitlines()[0])
|
|
1176
|
+
parser.add_argument("--serve", action="store_true")
|
|
1177
|
+
parser.add_argument("--self-test", metavar="FILE")
|
|
1178
|
+
parser.add_argument("--region",
|
|
1179
|
+
help="Bedrock region; required, from config/voice-region "
|
|
1180
|
+
"or FM_VOICE_REGION when not given here")
|
|
1181
|
+
parser.add_argument("--model",
|
|
1182
|
+
help="Nova Sonic model id; required, from config/voice-model "
|
|
1183
|
+
"or FM_VOICE_MODEL when not given here")
|
|
1184
|
+
parser.add_argument("--profile",
|
|
1185
|
+
help="AWS profile; optional, from config/voice-profile or "
|
|
1186
|
+
"FM_VOICE_PROFILE, and empty means the credentials "
|
|
1187
|
+
"already in the environment")
|
|
1188
|
+
parser.add_argument("--voice",
|
|
1189
|
+
help="output voice; from config/voice-id or FM_VOICE_ID, "
|
|
1190
|
+
"default {}".format(VOICE))
|
|
1191
|
+
parser.add_argument("--home")
|
|
1192
|
+
parser.add_argument("--scope", choices=records.SCOPES)
|
|
1193
|
+
parser.add_argument("--tail-ms", type=int, default=TAIL_MS)
|
|
1194
|
+
parser.add_argument("--turn-timeout", type=float, default=40.0,
|
|
1195
|
+
help="seconds --self-test waits for a reply")
|
|
1196
|
+
parser.add_argument("--verbose", action="store_true")
|
|
1197
|
+
options = parser.parse_args(argv)
|
|
1198
|
+
if options.tail_ms < 0:
|
|
1199
|
+
parser.error("--tail-ms cannot be negative")
|
|
1200
|
+
return options
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def resolve_settings(options):
|
|
1204
|
+
"""Fill in what this home configures, refusing rather than guessing.
|
|
1205
|
+
|
|
1206
|
+
Deliberately not part of parse_args: --help and the flags this file can
|
|
1207
|
+
answer for itself must work in a home that has configured nothing, and only
|
|
1208
|
+
a run that is about to reach Bedrock needs to know whose account it is.
|
|
1209
|
+
"""
|
|
1210
|
+
home = options.home or records.default_home()
|
|
1211
|
+
options.home = home
|
|
1212
|
+
if not options.region:
|
|
1213
|
+
options.region = records.require_setting(home, *SETTINGS["region"])
|
|
1214
|
+
if not options.model:
|
|
1215
|
+
options.model = records.require_setting(home, *SETTINGS["model"])
|
|
1216
|
+
if options.profile is None:
|
|
1217
|
+
# Presence, not truthiness: an empty FM_VOICE_PROFILE is the captain
|
|
1218
|
+
# saying "use the credentials I already have" and must not fall through
|
|
1219
|
+
# to a configured profile, which is how fm-inbox.sh reads its own
|
|
1220
|
+
# equivalent and what docs/configuration.md promises for both. An empty
|
|
1221
|
+
# region or model is still nothing, so those keep falling through.
|
|
1222
|
+
name, env = SETTINGS["profile"][:2]
|
|
1223
|
+
chosen = os.environ.get(env)
|
|
1224
|
+
if chosen is None:
|
|
1225
|
+
chosen = records.read_setting(home, name)
|
|
1226
|
+
options.profile = (chosen or "").strip()
|
|
1227
|
+
if not options.voice:
|
|
1228
|
+
options.voice = records.read_setting(home, *SETTINGS["voice"][:2]) or VOICE
|
|
1229
|
+
return options
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def main(argv):
|
|
1233
|
+
options = parse_args(argv)
|
|
1234
|
+
try:
|
|
1235
|
+
resolve_settings(options)
|
|
1236
|
+
if options.self_test:
|
|
1237
|
+
return asyncio.run(self_test(options))
|
|
1238
|
+
return asyncio.run(serve(options)) or 0
|
|
1239
|
+
except (records.RecordError, CredentialError) as exc:
|
|
1240
|
+
sys.stderr.write("fm-voice-relay: {}\n".format(exc))
|
|
1241
|
+
return 2
|
|
1242
|
+
except KeyboardInterrupt:
|
|
1243
|
+
return 130
|
|
1244
|
+
except Exception as exc: # noqa: BLE001
|
|
1245
|
+
# The captain reads this stderr over SSH, so a failure that gets this
|
|
1246
|
+
# far says what it was in one line. --verbose still gets the traceback,
|
|
1247
|
+
# because whoever passed it is debugging rather than talking.
|
|
1248
|
+
sys.stderr.write("fm-voice-relay: {}: {}\n".format(
|
|
1249
|
+
type(exc).__name__, exc))
|
|
1250
|
+
if options.verbose:
|
|
1251
|
+
traceback.print_exc()
|
|
1252
|
+
return 2
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
if __name__ == "__main__":
|
|
1256
|
+
sys.exit(main(sys.argv[1:]))
|