@zerwiz/ymir 0.1.4 → 0.1.7

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