@zerwiz/ymir 0.1.4 → 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.
Files changed (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -59
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. 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:]))