agentix-cli 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +67 -14
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +20 -3
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +24 -31
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +16 -2
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +173 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +62 -15
- package/dist/{App-KE4SURSS.js → App-QVNQ5BQK.js} +2 -2
- package/dist/{ChatApp-NNHIKXO2.js → ChatApp-CC57XC23.js} +2 -2
- package/dist/agent-ILH6ANOM.js +2 -0
- package/dist/{article-fields-L4D2BOGY.js → article-fields-PITN4NHA.js} +2 -2
- package/dist/{article-quality-44ONVGLN.js → article-quality-JFJMZWFQ.js} +2 -2
- package/dist/{audit-4245CX6P.js → audit-CMJ2TZ3K.js} +2 -2
- package/dist/{backfill-MTD2B62I.js → backfill-7TCF4SHG.js} +2 -2
- package/dist/{board-dashboard-RUOASCSQ.js → board-dashboard-XPPBRPQE.js} +117 -117
- package/dist/board-dashboard-XPPBRPQE.js.map +1 -0
- package/dist/{builtin-B67DMJ3M.js → builtin-W6KNX7RS.js} +2 -2
- package/dist/calibration-MCCVYGZQ.js +2 -0
- package/dist/{chunk-3SCPRYJB.js → chunk-2AXFYDWF.js} +2 -2
- package/dist/{chunk-4TITGMPX.js → chunk-2KQTE4XI.js} +2 -2
- package/dist/{chunk-QDE75R4U.js → chunk-6HAZ6YWI.js} +2 -2
- package/dist/{chunk-F4HZTZHW.js → chunk-72SZI2UW.js} +2 -2
- package/dist/{chunk-L2WSPY6S.js → chunk-7I7IEX4S.js} +2 -2
- package/dist/{chunk-OTW5MPFT.js → chunk-CAA546XN.js} +2 -2
- package/dist/{chunk-5XQCGPQJ.js → chunk-F7WY24UZ.js} +2 -2
- package/dist/{chunk-ZTEU5JVQ.js → chunk-FCPTN6NF.js} +2 -2
- package/dist/{chunk-AJONZWLT.js → chunk-FX3D7YON.js} +2 -2
- package/dist/{chunk-5ALS7SVV.js → chunk-H2TWRZC2.js} +2 -2
- package/dist/{chunk-WPMX3VGI.js → chunk-J3SOEFRE.js} +2 -2
- package/dist/{chunk-4NBWF4TL.js → chunk-JQR3NTHI.js} +2 -2
- package/dist/{chunk-OUZ7YVQZ.js → chunk-KFALP5NO.js} +2 -2
- package/dist/{chunk-Y6I3BYWV.js → chunk-LBMZP7TG.js} +95 -95
- package/dist/chunk-LBMZP7TG.js.map +1 -0
- package/dist/{chunk-WQLBJ2O5.js → chunk-PSDCRZZM.js} +6 -6
- package/dist/chunk-QIJLFPWE.js +2 -0
- package/dist/{chunk-PZCTCCC5.js → chunk-QP6XNTBB.js} +2 -2
- package/dist/{chunk-BH3GTCZ6.js → chunk-RGDKHYY2.js} +2 -2
- package/dist/{chunk-JVNFRS26.js → chunk-ROCUOXCW.js} +2 -2
- package/dist/{chunk-FULEQYM6.js → chunk-S7CVSKOX.js} +2 -2
- package/dist/chunk-S7CVSKOX.js.map +1 -0
- package/dist/{chunk-K33GIBSE.js → chunk-SIU6STB2.js} +2 -2
- package/dist/{chunk-P2ZYT5D6.js → chunk-SRCXUZM2.js} +5 -5
- package/dist/{chunk-P2ZYT5D6.js.map → chunk-SRCXUZM2.js.map} +1 -1
- package/dist/{chunk-LCIREYTD.js → chunk-SZJMS7K4.js} +2 -2
- package/dist/{chunk-AJ5RMU7X.js → chunk-TFVUOC2C.js} +2 -2
- package/dist/chunk-U67EG5IF.js +19 -0
- package/dist/chunk-U67EG5IF.js.map +1 -0
- package/dist/{chunk-BYD55JXH.js → chunk-UBRMOTBH.js} +181 -180
- package/dist/chunk-UBRMOTBH.js.map +1 -0
- package/dist/chunk-V7XZN3MQ.js +123 -0
- package/dist/chunk-V7XZN3MQ.js.map +1 -0
- package/dist/{chunk-NLZD2QKF.js → chunk-VMS3HVTT.js} +2 -2
- package/dist/{chunk-TUNQFPVA.js → chunk-WCMQRSVU.js} +2 -2
- package/dist/{chunk-OHA3J5ZV.js → chunk-WDQ3BPFS.js} +2 -2
- package/dist/{chunk-6VKCUMCN.js → chunk-WKTFP3SK.js} +2 -2
- package/dist/{chunk-Q7C3X3R6.js → chunk-WP6KLXTP.js} +2 -2
- package/dist/{chunk-IXUEJBHD.js → chunk-XZTR5MBJ.js} +2 -2
- package/dist/{chunk-W5C2GDNC.js → chunk-Y57CULGE.js} +2 -2
- package/dist/chunk-YE2BUU3Z.js +28 -0
- package/dist/chunk-YE2BUU3Z.js.map +1 -0
- package/dist/{chunk-TTL37CS5.js → chunk-YEV6W34S.js} +55 -55
- package/dist/{chunk-TTL37CS5.js.map → chunk-YEV6W34S.js.map} +1 -1
- package/dist/cli.js +1 -1
- package/dist/client-I7GAAM3T.js +2 -0
- package/dist/{compaction-PIZINFHR.js → compaction-NWPBPWY2.js} +4 -4
- package/dist/{config-R47ITLIY.js → config-TVW7UCKG.js} +2 -2
- package/dist/config-mutator-JHM53P5A.js +2 -0
- package/dist/{conflict-detector-ZPOT3M2C.js → conflict-detector-KS7OSS45.js} +2 -2
- package/dist/{context-planner-FJMU5WAT.js → context-planner-LJT5IKH6.js} +3 -3
- package/dist/debug-FTAGG7MN.js +2 -0
- package/dist/{discover-D4VVRNAI.js → discover-7YFQNYLC.js} +2 -2
- package/dist/doctor-CXFMV6PT.js +2 -0
- package/dist/{editor-chat-MOMQHMO4.js → editor-chat-V24MXQIB.js} +2 -2
- package/dist/{entry-triage-LK34TSQD.js → entry-triage-LZWMMLLN.js} +2 -2
- package/dist/{facts-YVEJOM7O.js → facts-4YPSHIXJ.js} +2 -2
- package/dist/{failure-candidates-ARRMFXRY.js → failure-candidates-RIXA3Y6D.js} +2 -2
- package/dist/heal-HVYQD3EZ.js +2 -0
- package/dist/index.d.ts +256 -6
- package/dist/index.js +2 -2
- package/dist/{ingest-whatsapp-NS4OWDOU.js → ingest-whatsapp-4POCTRTP.js} +2 -2
- package/dist/loader-EVHZ2R73.js +2 -0
- package/dist/loader-KKMG24O5.js +2 -0
- package/dist/memory-extract-MGGXIZTE.js +2 -0
- package/dist/{migrate-v2-V4WN2VVM.js → migrate-v2-5VGHWPBW.js} +2 -2
- package/dist/{ntfy-X7EPB3TG.js → ntfy-ZY2NPGBA.js} +2 -2
- package/dist/processes-CMTMB7LG.js +2 -0
- package/dist/program-ZCFMO4ZD.js +2 -0
- package/dist/{projects-api-QE6L65SK.js → projects-api-E337EQNV.js} +2 -2
- package/dist/{projects-mutate-MY7JTLSZ.js → projects-mutate-S5MY7V3V.js} +2 -2
- package/dist/promote-4T64VW5K.js +2 -0
- package/dist/proposals-O4B5LD5T.js +2 -0
- package/dist/providers-NRPX2FHP.js +2 -0
- package/dist/query-6IKL52WT.js +2 -0
- package/dist/{questions-4B4KSPXG.js → questions-2V2VGIT4.js} +2 -2
- package/dist/recipes-423CIPZ3.js +2 -0
- package/dist/registry-E4QUXHMQ.js +2 -0
- package/dist/{request-planner-PEAYYXAP.js → request-planner-BRLDCYU5.js} +2 -2
- package/dist/{review-candidates-W7NIHNAI.js → review-candidates-QWW5PDUU.js} +2 -2
- package/dist/{review-store-B4YE3EFK.js → review-store-PW3OBGZQ.js} +2 -2
- package/dist/{rotation-memo-QEO3COFH.js → rotation-memo-453FXKHQ.js} +2 -2
- package/dist/routing-DP6S7OJL.js +2 -0
- package/dist/runner-56G3N7M4.js +2 -0
- package/dist/schedule-tool-TPJ6GR47.js +2 -0
- package/dist/scheduler-7QPG6OTY.js +2 -0
- package/dist/seat-3KUNRM5Z.js +2 -0
- package/dist/{select-4XYRIZ5L.js → select-ILXHYJGD.js} +2 -2
- package/dist/shutdown-ZAF7RW25.js +2 -0
- package/dist/store-TWQPEM5V.js +2 -0
- package/dist/{subagent-2SJHSSJS.js → subagent-XY5N7TUU.js} +2 -2
- package/dist/{surface-usage-V2BX4SBD.js → surface-usage-HVVP22SA.js} +2 -2
- package/dist/timers-KK7GF77Y.js +2 -0
- package/dist/{tool-PAQ47HV2.js → tool-SF3BCY55.js} +2 -2
- package/dist/{triage-backtest-JNO7RKX7.js → triage-backtest-DLKYJSEB.js} +2 -2
- package/dist/types-DO3KLIBS.js +2 -0
- package/dist/types-S6NHM5C3.js +2 -0
- package/dist/{whatsapp-state-6D6FHHD2.js → whatsapp-state-LB3R32BK.js} +2 -2
- package/dist/wiki-WETANN3W.js +2 -0
- package/dist/{workspace-setup-G6KGWPHY.js → workspace-setup-42ARC42I.js} +2 -2
- package/package.json +1 -1
- package/dist/agent-RY7Z3IYY.js +0 -2
- package/dist/board-dashboard-RUOASCSQ.js.map +0 -1
- package/dist/calibration-ILS5OI44.js +0 -2
- package/dist/chunk-B5XHX44Y.js +0 -123
- package/dist/chunk-B5XHX44Y.js.map +0 -1
- package/dist/chunk-BYD55JXH.js.map +0 -1
- package/dist/chunk-EMI3VJLH.js +0 -28
- package/dist/chunk-EMI3VJLH.js.map +0 -1
- package/dist/chunk-FIOB3MEX.js +0 -2
- package/dist/chunk-FULEQYM6.js.map +0 -1
- package/dist/chunk-N2XXKFJ6.js +0 -19
- package/dist/chunk-N2XXKFJ6.js.map +0 -1
- package/dist/chunk-Y6I3BYWV.js.map +0 -1
- package/dist/client-JZTH46N2.js +0 -2
- package/dist/config-mutator-T6JGXJ26.js +0 -2
- package/dist/debug-3MEUO2P2.js +0 -2
- package/dist/doctor-2DMGAWMW.js +0 -2
- package/dist/heal-FVLBHYP7.js +0 -2
- package/dist/loader-4ZGTK7LD.js +0 -2
- package/dist/loader-BOKE5M5S.js +0 -2
- package/dist/memory-extract-OWTI6MN5.js +0 -2
- package/dist/processes-KUJJ7XE7.js +0 -2
- package/dist/program-KY5ZYPFN.js +0 -2
- package/dist/promote-57YNOVXV.js +0 -2
- package/dist/proposals-TX2HBBYV.js +0 -2
- package/dist/providers-KO2DODIO.js +0 -2
- package/dist/query-ZT7WXY2F.js +0 -2
- package/dist/recipes-7S4MC5OJ.js +0 -2
- package/dist/registry-LLAFJJN4.js +0 -2
- package/dist/routing-7LCO7UJ7.js +0 -2
- package/dist/runner-YRBVNYTY.js +0 -2
- package/dist/schedule-tool-ZD3VBUBW.js +0 -2
- package/dist/scheduler-GC3FQKXY.js +0 -2
- package/dist/seat-HR6QMAR2.js +0 -2
- package/dist/shutdown-6GCMKYAD.js +0 -2
- package/dist/store-MCOA6N4F.js +0 -2
- package/dist/timers-PCB4ZIBD.js +0 -2
- package/dist/types-H5ENIUIT.js +0 -2
- package/dist/types-OIFU3KSQ.js +0 -2
- package/dist/wiki-EREMPSAE.js +0 -2
- /package/dist/{App-KE4SURSS.js.map → App-QVNQ5BQK.js.map} +0 -0
- /package/dist/{ChatApp-NNHIKXO2.js.map → ChatApp-CC57XC23.js.map} +0 -0
- /package/dist/{agent-RY7Z3IYY.js.map → agent-ILH6ANOM.js.map} +0 -0
- /package/dist/{article-fields-L4D2BOGY.js.map → article-fields-PITN4NHA.js.map} +0 -0
- /package/dist/{article-quality-44ONVGLN.js.map → article-quality-JFJMZWFQ.js.map} +0 -0
- /package/dist/{audit-4245CX6P.js.map → audit-CMJ2TZ3K.js.map} +0 -0
- /package/dist/{backfill-MTD2B62I.js.map → backfill-7TCF4SHG.js.map} +0 -0
- /package/dist/{builtin-B67DMJ3M.js.map → builtin-W6KNX7RS.js.map} +0 -0
- /package/dist/{calibration-ILS5OI44.js.map → calibration-MCCVYGZQ.js.map} +0 -0
- /package/dist/{chunk-3SCPRYJB.js.map → chunk-2AXFYDWF.js.map} +0 -0
- /package/dist/{chunk-4TITGMPX.js.map → chunk-2KQTE4XI.js.map} +0 -0
- /package/dist/{chunk-QDE75R4U.js.map → chunk-6HAZ6YWI.js.map} +0 -0
- /package/dist/{chunk-F4HZTZHW.js.map → chunk-72SZI2UW.js.map} +0 -0
- /package/dist/{chunk-L2WSPY6S.js.map → chunk-7I7IEX4S.js.map} +0 -0
- /package/dist/{chunk-OTW5MPFT.js.map → chunk-CAA546XN.js.map} +0 -0
- /package/dist/{chunk-5XQCGPQJ.js.map → chunk-F7WY24UZ.js.map} +0 -0
- /package/dist/{chunk-ZTEU5JVQ.js.map → chunk-FCPTN6NF.js.map} +0 -0
- /package/dist/{chunk-AJONZWLT.js.map → chunk-FX3D7YON.js.map} +0 -0
- /package/dist/{chunk-5ALS7SVV.js.map → chunk-H2TWRZC2.js.map} +0 -0
- /package/dist/{chunk-WPMX3VGI.js.map → chunk-J3SOEFRE.js.map} +0 -0
- /package/dist/{chunk-4NBWF4TL.js.map → chunk-JQR3NTHI.js.map} +0 -0
- /package/dist/{chunk-OUZ7YVQZ.js.map → chunk-KFALP5NO.js.map} +0 -0
- /package/dist/{chunk-WQLBJ2O5.js.map → chunk-PSDCRZZM.js.map} +0 -0
- /package/dist/{chunk-FIOB3MEX.js.map → chunk-QIJLFPWE.js.map} +0 -0
- /package/dist/{chunk-PZCTCCC5.js.map → chunk-QP6XNTBB.js.map} +0 -0
- /package/dist/{chunk-BH3GTCZ6.js.map → chunk-RGDKHYY2.js.map} +0 -0
- /package/dist/{chunk-JVNFRS26.js.map → chunk-ROCUOXCW.js.map} +0 -0
- /package/dist/{chunk-K33GIBSE.js.map → chunk-SIU6STB2.js.map} +0 -0
- /package/dist/{chunk-LCIREYTD.js.map → chunk-SZJMS7K4.js.map} +0 -0
- /package/dist/{chunk-AJ5RMU7X.js.map → chunk-TFVUOC2C.js.map} +0 -0
- /package/dist/{chunk-NLZD2QKF.js.map → chunk-VMS3HVTT.js.map} +0 -0
- /package/dist/{chunk-TUNQFPVA.js.map → chunk-WCMQRSVU.js.map} +0 -0
- /package/dist/{chunk-OHA3J5ZV.js.map → chunk-WDQ3BPFS.js.map} +0 -0
- /package/dist/{chunk-6VKCUMCN.js.map → chunk-WKTFP3SK.js.map} +0 -0
- /package/dist/{chunk-Q7C3X3R6.js.map → chunk-WP6KLXTP.js.map} +0 -0
- /package/dist/{chunk-IXUEJBHD.js.map → chunk-XZTR5MBJ.js.map} +0 -0
- /package/dist/{chunk-W5C2GDNC.js.map → chunk-Y57CULGE.js.map} +0 -0
- /package/dist/{client-JZTH46N2.js.map → client-I7GAAM3T.js.map} +0 -0
- /package/dist/{compaction-PIZINFHR.js.map → compaction-NWPBPWY2.js.map} +0 -0
- /package/dist/{config-R47ITLIY.js.map → config-TVW7UCKG.js.map} +0 -0
- /package/dist/{config-mutator-T6JGXJ26.js.map → config-mutator-JHM53P5A.js.map} +0 -0
- /package/dist/{conflict-detector-ZPOT3M2C.js.map → conflict-detector-KS7OSS45.js.map} +0 -0
- /package/dist/{context-planner-FJMU5WAT.js.map → context-planner-LJT5IKH6.js.map} +0 -0
- /package/dist/{debug-3MEUO2P2.js.map → debug-FTAGG7MN.js.map} +0 -0
- /package/dist/{discover-D4VVRNAI.js.map → discover-7YFQNYLC.js.map} +0 -0
- /package/dist/{doctor-2DMGAWMW.js.map → doctor-CXFMV6PT.js.map} +0 -0
- /package/dist/{editor-chat-MOMQHMO4.js.map → editor-chat-V24MXQIB.js.map} +0 -0
- /package/dist/{entry-triage-LK34TSQD.js.map → entry-triage-LZWMMLLN.js.map} +0 -0
- /package/dist/{facts-YVEJOM7O.js.map → facts-4YPSHIXJ.js.map} +0 -0
- /package/dist/{failure-candidates-ARRMFXRY.js.map → failure-candidates-RIXA3Y6D.js.map} +0 -0
- /package/dist/{heal-FVLBHYP7.js.map → heal-HVYQD3EZ.js.map} +0 -0
- /package/dist/{ingest-whatsapp-NS4OWDOU.js.map → ingest-whatsapp-4POCTRTP.js.map} +0 -0
- /package/dist/{loader-4ZGTK7LD.js.map → loader-EVHZ2R73.js.map} +0 -0
- /package/dist/{loader-BOKE5M5S.js.map → loader-KKMG24O5.js.map} +0 -0
- /package/dist/{memory-extract-OWTI6MN5.js.map → memory-extract-MGGXIZTE.js.map} +0 -0
- /package/dist/{migrate-v2-V4WN2VVM.js.map → migrate-v2-5VGHWPBW.js.map} +0 -0
- /package/dist/{ntfy-X7EPB3TG.js.map → ntfy-ZY2NPGBA.js.map} +0 -0
- /package/dist/{processes-KUJJ7XE7.js.map → processes-CMTMB7LG.js.map} +0 -0
- /package/dist/{program-KY5ZYPFN.js.map → program-ZCFMO4ZD.js.map} +0 -0
- /package/dist/{projects-api-QE6L65SK.js.map → projects-api-E337EQNV.js.map} +0 -0
- /package/dist/{projects-mutate-MY7JTLSZ.js.map → projects-mutate-S5MY7V3V.js.map} +0 -0
- /package/dist/{promote-57YNOVXV.js.map → promote-4T64VW5K.js.map} +0 -0
- /package/dist/{proposals-TX2HBBYV.js.map → proposals-O4B5LD5T.js.map} +0 -0
- /package/dist/{providers-KO2DODIO.js.map → providers-NRPX2FHP.js.map} +0 -0
- /package/dist/{query-ZT7WXY2F.js.map → query-6IKL52WT.js.map} +0 -0
- /package/dist/{questions-4B4KSPXG.js.map → questions-2V2VGIT4.js.map} +0 -0
- /package/dist/{recipes-7S4MC5OJ.js.map → recipes-423CIPZ3.js.map} +0 -0
- /package/dist/{registry-LLAFJJN4.js.map → registry-E4QUXHMQ.js.map} +0 -0
- /package/dist/{request-planner-PEAYYXAP.js.map → request-planner-BRLDCYU5.js.map} +0 -0
- /package/dist/{review-candidates-W7NIHNAI.js.map → review-candidates-QWW5PDUU.js.map} +0 -0
- /package/dist/{review-store-B4YE3EFK.js.map → review-store-PW3OBGZQ.js.map} +0 -0
- /package/dist/{rotation-memo-QEO3COFH.js.map → rotation-memo-453FXKHQ.js.map} +0 -0
- /package/dist/{routing-7LCO7UJ7.js.map → routing-DP6S7OJL.js.map} +0 -0
- /package/dist/{runner-YRBVNYTY.js.map → runner-56G3N7M4.js.map} +0 -0
- /package/dist/{schedule-tool-ZD3VBUBW.js.map → schedule-tool-TPJ6GR47.js.map} +0 -0
- /package/dist/{scheduler-GC3FQKXY.js.map → scheduler-7QPG6OTY.js.map} +0 -0
- /package/dist/{seat-HR6QMAR2.js.map → seat-3KUNRM5Z.js.map} +0 -0
- /package/dist/{select-4XYRIZ5L.js.map → select-ILXHYJGD.js.map} +0 -0
- /package/dist/{shutdown-6GCMKYAD.js.map → shutdown-ZAF7RW25.js.map} +0 -0
- /package/dist/{store-MCOA6N4F.js.map → store-TWQPEM5V.js.map} +0 -0
- /package/dist/{subagent-2SJHSSJS.js.map → subagent-XY5N7TUU.js.map} +0 -0
- /package/dist/{surface-usage-V2BX4SBD.js.map → surface-usage-HVVP22SA.js.map} +0 -0
- /package/dist/{timers-PCB4ZIBD.js.map → timers-KK7GF77Y.js.map} +0 -0
- /package/dist/{tool-PAQ47HV2.js.map → tool-SF3BCY55.js.map} +0 -0
- /package/dist/{triage-backtest-JNO7RKX7.js.map → triage-backtest-DLKYJSEB.js.map} +0 -0
- /package/dist/{types-H5ENIUIT.js.map → types-DO3KLIBS.js.map} +0 -0
- /package/dist/{types-OIFU3KSQ.js.map → types-S6NHM5C3.js.map} +0 -0
- /package/dist/{whatsapp-state-6D6FHHD2.js.map → whatsapp-state-LB3R32BK.js.map} +0 -0
- /package/dist/{wiki-EREMPSAE.js.map → wiki-WETANN3W.js.map} +0 -0
- /package/dist/{workspace-setup-G6KGWPHY.js.map → workspace-setup-42ARC42I.js.map} +0 -0
|
@@ -63,7 +63,7 @@ enum AgentClient {
|
|
|
63
63
|
|
|
64
64
|
/// What was speaking when the door opened.
|
|
65
65
|
struct Hushed {
|
|
66
|
-
/// "talk", "lesson" or "
|
|
66
|
+
/// "talk", "lesson", "narration" or "queue"; nil when nothing was.
|
|
67
67
|
let kind: String?
|
|
68
68
|
let agentID: String?
|
|
69
69
|
static let nothing = Hushed(kind: nil, agentID: nil)
|
|
@@ -84,6 +84,39 @@ enum AgentClient {
|
|
|
84
84
|
_ = try? await post("/voice/stop", [:], timeout: 2)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/// The listener said nothing after the hush, so no door follows: the
|
|
88
|
+
/// daemon's speaking queue plays on.
|
|
89
|
+
static func resume() async {
|
|
90
|
+
_ = try? await post("/voice/queue/resume", [:], timeout: 2)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MARK: The speaking queue
|
|
94
|
+
//
|
|
95
|
+
// Everything spoken on this Mac waits in one queue on the daemon, so
|
|
96
|
+
// an answer never plays over another agent, a talk or narration. See
|
|
97
|
+
// src/daemon/voice-queue-api.ts.
|
|
98
|
+
|
|
99
|
+
/// Queue a line in the agent's voice and wait until it has been
|
|
100
|
+
/// spoken, skipped or stopped. False only when the daemon could not
|
|
101
|
+
/// take it, so the caller speaks it here instead. A cancelled wait
|
|
102
|
+
/// (the door opened) is true: the line stays queued and plays after
|
|
103
|
+
/// the listener's turn.
|
|
104
|
+
static func queue(_ text: String, agentID: String, kind: String) async -> Bool {
|
|
105
|
+
do {
|
|
106
|
+
// A line may wait behind others, and a held queue for a minute.
|
|
107
|
+
let (_, response) = try await post("/voice/queue",
|
|
108
|
+
["text": text, "agentId": agentID, "kind": kind, "wait": true], timeout: 900)
|
|
109
|
+
guard let http = response as? HTTPURLResponse else { return false }
|
|
110
|
+
return (200..<300).contains(http.statusCode)
|
|
111
|
+
} catch let e as URLError where [.cannotConnectToHost, .cannotFindHost, .notConnectedToInternet, .networkConnectionLost].contains(e.code) {
|
|
112
|
+
return false
|
|
113
|
+
} catch {
|
|
114
|
+
// Cancelled or timed out after the daemon took the line:
|
|
115
|
+
// playing it here too would talk over it.
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
/// Hand the listener's words through the door. True when an activity
|
|
88
121
|
/// took them (a talk or lesson answers; "stop" ends it); false means
|
|
89
122
|
/// nothing did, and they are an ordinary question.
|
|
@@ -102,20 +135,40 @@ enum AgentClient {
|
|
|
102
135
|
return try await URLSession.shared.data(for: req)
|
|
103
136
|
}
|
|
104
137
|
|
|
105
|
-
///
|
|
106
|
-
|
|
138
|
+
/// One row of GET /agents: who can answer, and whether they are busy.
|
|
139
|
+
struct AgentInfo: Decodable {
|
|
140
|
+
let id: String
|
|
141
|
+
let name: String?
|
|
142
|
+
/// Tasks running now.
|
|
143
|
+
let active: Int?
|
|
144
|
+
var label: String { name ?? id }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// The daemon's agents, or nil when the daemon cannot be reached.
|
|
148
|
+
static func agents() async -> [AgentInfo]? {
|
|
149
|
+
guard let url = URL(string: "\(Config.daemonURL)/agents") else { return nil }
|
|
150
|
+
var req = URLRequest(url: url)
|
|
151
|
+
req.timeoutInterval = 3
|
|
152
|
+
guard let (data, response) = try? await URLSession.shared.data(for: req),
|
|
153
|
+
(response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
|
154
|
+
return try? JSONDecoder().decode([AgentInfo].self, from: data)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The agent that answers: the pinned one, the one picked in the menu,
|
|
158
|
+
/// the daemon's `node.defaultAgent` from /health, or its first agent —
|
|
159
|
+
/// /ask refuses a request with no agent when there is no default.
|
|
160
|
+
/// "" when none is known.
|
|
107
161
|
static func resolveAgent() async -> String {
|
|
108
|
-
if let agent = Config.agentID { return agent }
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return agent
|
|
162
|
+
if let agent = Config.agentID ?? Config.chosenAgentID { return agent }
|
|
163
|
+
if let url = URL(string: "\(Config.daemonURL)/health"),
|
|
164
|
+
let (data, _) = try? await URLSession.shared.data(from: url),
|
|
165
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
166
|
+
let node = obj["node"] as? [String: Any],
|
|
167
|
+
let agent = node["defaultAgent"] as? String, !agent.isEmpty { return agent }
|
|
168
|
+
return await agents()?.first?.id ?? ""
|
|
116
169
|
}
|
|
117
170
|
|
|
118
|
-
static func ask(_ message: String) async throws -> Answer {
|
|
171
|
+
static func ask(_ message: String, agent: String) async throws -> Answer {
|
|
119
172
|
var req = URLRequest(url: URL(string: "\(Config.daemonURL)/ask")!)
|
|
120
173
|
req.httpMethod = "POST"
|
|
121
174
|
// An agent turn routinely takes minutes. The default 60s would cut
|
|
@@ -123,8 +176,8 @@ enum AgentClient {
|
|
|
123
176
|
req.timeoutInterval = 600
|
|
124
177
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
125
178
|
var payload: [String: Any] = ["message": message, "session": Config.voiceSession]
|
|
126
|
-
// No agent
|
|
127
|
-
if
|
|
179
|
+
// No agent known: the daemon answers with its default agent.
|
|
180
|
+
if !agent.isEmpty { payload["agent"] = agent }
|
|
128
181
|
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
|
129
182
|
|
|
130
183
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
@@ -8,11 +8,28 @@ import Foundation
|
|
|
8
8
|
/// from a terminal and mysteriously fails when you double-click it.
|
|
9
9
|
enum Config {
|
|
10
10
|
static let daemonURL = env("AGENTX_DAEMON_URL") ?? "http://127.0.0.1:18800"
|
|
11
|
-
/// The
|
|
11
|
+
/// The dashboard, for the menu's Settings… and History….
|
|
12
|
+
static let dashboardURL = env("AGENTX_DASHBOARD_URL") ?? "http://127.0.0.1:4202"
|
|
13
|
+
/// An agent pinned by the environment. Wins over the menu's choice.
|
|
12
14
|
static let agentID = env("AGENTX_VOICE_AGENT")
|
|
13
15
|
|
|
14
|
-
/// The agent
|
|
15
|
-
///
|
|
16
|
+
/// The agent picked in the menu, remembered across launches. A UI
|
|
17
|
+
/// choice, so UserDefaults rather than agentx.json.
|
|
18
|
+
static var chosenAgentID: String? {
|
|
19
|
+
get { UserDefaults.standard.string(forKey: "targetAgent") }
|
|
20
|
+
set { UserDefaults.standard.set(newValue, forKey: "targetAgent") }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// The floating pill stays on screen when idle. Off by default: the
|
|
24
|
+
/// menu-bar icon shows the state, and the pill appears only while
|
|
25
|
+
/// listening or answering.
|
|
26
|
+
static var showPill: Bool {
|
|
27
|
+
get { UserDefaults.standard.bool(forKey: "showPill") }
|
|
28
|
+
set { UserDefaults.standard.set(newValue, forKey: "showPill") }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The agent actually answering: `agentID`, else `chosenAgentID`, else
|
|
32
|
+
/// the daemon's default (AgentClient.resolveAgent). Progress follows it.
|
|
16
33
|
@MainActor static var effectiveAgentID: String = ""
|
|
17
34
|
|
|
18
35
|
/// Who speaks before the daemon has said which voice an agent uses:
|
|
@@ -36,43 +36,23 @@ final class Panel: NSPanel {
|
|
|
36
36
|
super.mouseDown(with: event)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/// Right-click opens the
|
|
40
|
-
///
|
|
41
|
-
/// A hold has to be reversible in one gesture from wherever you are,
|
|
42
|
-
/// and it has to SHOW — a switch that silences your notifications
|
|
43
|
-
/// without saying so is how people miss things for a day and blame the
|
|
44
|
-
/// software.
|
|
39
|
+
/// Right-click opens the same menu as the menu-bar icon.
|
|
45
40
|
override func rightMouseDown(with event: NSEvent) {
|
|
46
|
-
let menu =
|
|
47
|
-
let holding = Hold.isOn
|
|
48
|
-
let item = NSMenuItem(
|
|
49
|
-
title: holding ? "Delivering notifications" : "Hold notifications",
|
|
50
|
-
action: #selector(toggleHold), keyEquivalent: "")
|
|
51
|
-
item.target = self
|
|
52
|
-
item.state = holding ? .on : .off
|
|
53
|
-
menu.addItem(item)
|
|
54
|
-
let stop = NSMenuItem(title: "Stop speaking ⌘⌥.", action: #selector(stopSpeaking), keyEquivalent: "")
|
|
55
|
-
stop.target = self
|
|
56
|
-
menu.addItem(stop)
|
|
57
|
-
menu.addItem(.separator())
|
|
58
|
-
let quit = NSMenuItem(title: "Quit AgentX Voice",
|
|
59
|
-
action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
60
|
-
menu.addItem(quit)
|
|
41
|
+
guard let menu = contextMenu?() else { return }
|
|
61
42
|
NSMenu.popUpContextMenu(menu, with: event, for: contentView ?? NSView())
|
|
62
43
|
}
|
|
63
44
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
onHoldChanged?(nowOn)
|
|
67
|
-
}
|
|
45
|
+
/// The menu to show on right-click. Set by the app.
|
|
46
|
+
var contextMenu: (() -> NSMenu)?
|
|
68
47
|
|
|
69
|
-
///
|
|
70
|
-
var
|
|
48
|
+
/// The target agent's name, shown while listening or answering.
|
|
49
|
+
var agentName: () -> String = { "" }
|
|
71
50
|
|
|
72
|
-
|
|
51
|
+
/// Stay on screen when idle. Off: the pill shows only while active.
|
|
52
|
+
var alwaysVisible = false
|
|
73
53
|
|
|
74
|
-
///
|
|
75
|
-
var
|
|
54
|
+
/// Told of every state rendered, so the menu-bar icon can follow.
|
|
55
|
+
var onRender: ((State) -> Void)?
|
|
76
56
|
|
|
77
57
|
override func mouseUp(with event: NSEvent) {
|
|
78
58
|
defer { pressedAt = nil }
|
|
@@ -225,8 +205,21 @@ final class Panel: NSPanel {
|
|
|
225
205
|
label.frame.origin.x = 0
|
|
226
206
|
} else {
|
|
227
207
|
label.textColor = state.color
|
|
228
|
-
setText(state
|
|
208
|
+
setText(named(state))
|
|
229
209
|
}
|
|
210
|
+
|
|
211
|
+
if state.isMeta && !alwaysVisible { orderOut(nil) } else { orderFrontRegardless() }
|
|
212
|
+
onRender?(state)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// "Nadia · Listening": who is listening or answering. Errors are the
|
|
216
|
+
/// widget's own, so they carry no name.
|
|
217
|
+
@MainActor
|
|
218
|
+
private func named(_ state: State) -> String {
|
|
219
|
+
let name = agentName()
|
|
220
|
+
if name.isEmpty { return state.text }
|
|
221
|
+
if case .error = state { return state.text }
|
|
222
|
+
return "\(name) · \(state.text)"
|
|
230
223
|
}
|
|
231
224
|
|
|
232
225
|
/// Fits, or scrolls. Identical text is left alone so a per-second tick
|
|
@@ -112,8 +112,22 @@ enum Speech {
|
|
|
112
112
|
await AgentClient.stopVoice()
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
///
|
|
116
|
-
///
|
|
115
|
+
/// Say a line through the daemon's speaking queue, in the agent's
|
|
116
|
+
/// voice, after whatever is already speaking. Played here only when
|
|
117
|
+
/// the daemon cannot be reached. `kind` is "answer", "narration" or
|
|
118
|
+
/// "line".
|
|
119
|
+
@MainActor
|
|
120
|
+
static func say(_ text: String, agentID: String?, kind: String, voice: VoiceChoice?) async {
|
|
121
|
+
guard !text.isEmpty else { return }
|
|
122
|
+
let agent = agentID ?? Config.effectiveAgentID
|
|
123
|
+
if !agent.isEmpty, await AgentClient.queue(text, agentID: agent, kind: kind) { return }
|
|
124
|
+
if Task.isCancelled { return }
|
|
125
|
+
await speak(text, voice: voice)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Speak here, now. `voice` is the answering agent's, as the daemon
|
|
129
|
+
/// resolved it; nil (no answer yet) means the provider this app is
|
|
130
|
+
/// configured with.
|
|
117
131
|
static func speak(_ text: String, voice: VoiceChoice? = nil) async {
|
|
118
132
|
guard !text.isEmpty else { return }
|
|
119
133
|
if (voice?.provider ?? Config.voiceProvider) == "elevenlabs" {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The menu-bar icon: what the assistant is doing, and who it talks to.
|
|
4
|
+
///
|
|
5
|
+
/// One menu serves the icon and the pill's right-click, so there is one
|
|
6
|
+
/// place that says which agent answers and one way to change it.
|
|
7
|
+
@MainActor
|
|
8
|
+
final class StatusMenu: NSObject, NSMenuDelegate {
|
|
9
|
+
let menu = NSMenu()
|
|
10
|
+
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
11
|
+
|
|
12
|
+
private enum Roster {
|
|
13
|
+
case loading
|
|
14
|
+
/// The daemon did not answer. Said so, with a retry, rather than
|
|
15
|
+
/// an empty list that looks like "you have no agents".
|
|
16
|
+
case down
|
|
17
|
+
case loaded([AgentClient.AgentInfo])
|
|
18
|
+
}
|
|
19
|
+
private var roster = Roster.loading
|
|
20
|
+
/// Reopen the menu once the retry's answer is in.
|
|
21
|
+
private var reopenAfterRefresh = false
|
|
22
|
+
|
|
23
|
+
/// Set by the app.
|
|
24
|
+
var onStop: (() -> Void)?
|
|
25
|
+
var onHoldChanged: ((Bool) -> Void)?
|
|
26
|
+
var onTargetChanged: ((String) -> Void)?
|
|
27
|
+
var onPillChanged: ((Bool) -> Void)?
|
|
28
|
+
|
|
29
|
+
override init() {
|
|
30
|
+
super.init()
|
|
31
|
+
menu.delegate = self
|
|
32
|
+
menu.autoenablesItems = false
|
|
33
|
+
item.menu = menu
|
|
34
|
+
show(.idle)
|
|
35
|
+
rebuild()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// The agent's display name, or its id until /agents has answered.
|
|
39
|
+
func name(of id: String) -> String {
|
|
40
|
+
guard case .loaded(let agents) = roster else { return id }
|
|
41
|
+
return agents.first { $0.id == id }?.label ?? id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Open the menu from the keyboard.
|
|
45
|
+
func open() { item.button?.performClick(nil) }
|
|
46
|
+
|
|
47
|
+
func refresh() {
|
|
48
|
+
Task {
|
|
49
|
+
let agents = await AgentClient.agents()
|
|
50
|
+
roster = agents.map { .loaded($0) } ?? .down
|
|
51
|
+
rebuild()
|
|
52
|
+
if reopenAfterRefresh { reopenAfterRefresh = false; open() }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// The icon follows the pill's state.
|
|
57
|
+
func show(_ state: Panel.State) {
|
|
58
|
+
let (symbol, label): (String, String) = switch state {
|
|
59
|
+
case .idle: Hold.isOn ? ("bell.slash", "Notifications held") : ("waveform", "Idle")
|
|
60
|
+
case .listening: ("mic.fill", "Listening")
|
|
61
|
+
case .thinking, .working: ("ellipsis.circle", "Thinking")
|
|
62
|
+
case .speaking, .saying: ("speaker.wave.2.fill", "Speaking")
|
|
63
|
+
case .error: ("exclamationmark.triangle", "Error")
|
|
64
|
+
}
|
|
65
|
+
guard item.button?.toolTip != "AgentX Voice — \(label)" else { return }
|
|
66
|
+
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "AgentX Voice: \(label)")
|
|
67
|
+
image?.isTemplate = true
|
|
68
|
+
item.button?.image = image
|
|
69
|
+
item.button?.toolTip = "AgentX Voice — \(label)"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: NSMenuDelegate
|
|
73
|
+
|
|
74
|
+
/// Show what is known now and refresh underneath; the open menu
|
|
75
|
+
/// updates in place when the answer arrives.
|
|
76
|
+
func menuWillOpen(_ menu: NSMenu) {
|
|
77
|
+
rebuild()
|
|
78
|
+
refresh()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// MARK: Building
|
|
82
|
+
|
|
83
|
+
private func rebuild() {
|
|
84
|
+
menu.removeAllItems()
|
|
85
|
+
let target = Config.effectiveAgentID
|
|
86
|
+
let pinned = Config.agentID != nil
|
|
87
|
+
|
|
88
|
+
switch roster {
|
|
89
|
+
case .loading:
|
|
90
|
+
menu.addItem(disabled("Loading agents…"))
|
|
91
|
+
case .down:
|
|
92
|
+
menu.addItem(disabled("AgentX daemon isn't reachable"))
|
|
93
|
+
menu.addItem(action("Retry", #selector(retry), key: "r", modifiers: []))
|
|
94
|
+
case .loaded(let agents):
|
|
95
|
+
menu.addItem(NSMenuItem.sectionHeader(title: pinned ? "Agent (set by AGENTX_VOICE_AGENT)" : "Talk to"))
|
|
96
|
+
if agents.isEmpty { menu.addItem(disabled("No agents configured")) }
|
|
97
|
+
for (i, agent) in agents.enumerated() {
|
|
98
|
+
let busy = (agent.active ?? 0) > 0
|
|
99
|
+
// Digits pick an agent while the menu is open.
|
|
100
|
+
let row = action(agent.label + (busy ? " · working" : " · idle"), #selector(pick(_:)),
|
|
101
|
+
key: i < 9 ? "\(i + 1)" : "", modifiers: [])
|
|
102
|
+
row.representedObject = agent.id
|
|
103
|
+
row.state = agent.id == target ? .on : .off
|
|
104
|
+
row.isEnabled = !pinned
|
|
105
|
+
menu.addItem(row)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
menu.addItem(.separator())
|
|
110
|
+
menu.addItem(action("Stop speaking", #selector(stop), key: ".", modifiers: [.command, .option]))
|
|
111
|
+
let hold = action("Hold notifications", #selector(toggleHold), key: "")
|
|
112
|
+
hold.state = Hold.isOn ? .on : .off
|
|
113
|
+
menu.addItem(hold)
|
|
114
|
+
let pill = action("Show floating pill", #selector(togglePill), key: "")
|
|
115
|
+
pill.state = Config.showPill ? .on : .off
|
|
116
|
+
menu.addItem(pill)
|
|
117
|
+
|
|
118
|
+
menu.addItem(.separator())
|
|
119
|
+
menu.addItem(action("Settings…", #selector(openSettings), key: ","))
|
|
120
|
+
menu.addItem(action("History…", #selector(openHistory), key: "y"))
|
|
121
|
+
menu.addItem(.separator())
|
|
122
|
+
menu.addItem(NSMenuItem(title: "Quit AgentX Voice",
|
|
123
|
+
action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func disabled(_ title: String) -> NSMenuItem {
|
|
127
|
+
let row = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
128
|
+
row.isEnabled = false
|
|
129
|
+
return row
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func action(_ title: String, _ selector: Selector, key: String,
|
|
133
|
+
modifiers: NSEvent.ModifierFlags = [.command]) -> NSMenuItem {
|
|
134
|
+
let row = NSMenuItem(title: title, action: selector, keyEquivalent: key)
|
|
135
|
+
row.keyEquivalentModifierMask = modifiers
|
|
136
|
+
row.target = self
|
|
137
|
+
return row
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: Actions
|
|
141
|
+
|
|
142
|
+
@objc private func pick(_ sender: NSMenuItem) {
|
|
143
|
+
guard let id = sender.representedObject as? String else { return }
|
|
144
|
+
Config.chosenAgentID = id
|
|
145
|
+
onTargetChanged?(id)
|
|
146
|
+
rebuild()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@objc private func retry() {
|
|
150
|
+
reopenAfterRefresh = true
|
|
151
|
+
roster = .loading
|
|
152
|
+
refresh()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@objc private func stop() { onStop?() }
|
|
156
|
+
|
|
157
|
+
@objc private func toggleHold() {
|
|
158
|
+
onHoldChanged?(Hold.toggle())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@objc private func togglePill() {
|
|
162
|
+
Config.showPill.toggle()
|
|
163
|
+
onPillChanged?(Config.showPill)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@objc private func openSettings() { openDashboard("/admin") }
|
|
167
|
+
@objc private func openHistory() { openDashboard("/activity") }
|
|
168
|
+
|
|
169
|
+
private func openDashboard(_ path: String) {
|
|
170
|
+
guard let url = URL(string: Config.dashboardURL + path) else { return }
|
|
171
|
+
NSWorkspace.shared.open(url)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -17,11 +17,13 @@ import Carbon.HIToolbox
|
|
|
17
17
|
@MainActor
|
|
18
18
|
final class App: NSObject, NSApplicationDelegate {
|
|
19
19
|
private let panel = Panel()
|
|
20
|
+
private let statusMenu = StatusMenu()
|
|
20
21
|
private let card = ResultCard()
|
|
21
22
|
private let recorder = Recorder()
|
|
22
23
|
private var hotkey: Hotkey?
|
|
23
24
|
private var pasteHotkey: Hotkey?
|
|
24
25
|
private var stopHotkey: Hotkey?
|
|
26
|
+
private var menuHotkey: Hotkey?
|
|
25
27
|
/// Set by a stop: the answer being spoken ends without reopening the mic.
|
|
26
28
|
private var silenced = false
|
|
27
29
|
private var busy = false
|
|
@@ -37,6 +39,9 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
37
39
|
/// Set when the door opens: what was speaking (and is now hushed), so
|
|
38
40
|
/// the words spoken go to that activity rather than to /ask.
|
|
39
41
|
private var talkCheck: Task<AgentClient.Hushed, Never>?
|
|
42
|
+
/// Waiting for our answer to be spoken by the daemon's queue. The door
|
|
43
|
+
/// lets go of it: the answer stays queued and plays after the turn.
|
|
44
|
+
private var speaking: Task<Void, Never>?
|
|
40
45
|
/// Words said while our own turn was still running: they go next, and
|
|
41
46
|
/// the stale answer is not spoken.
|
|
42
47
|
private var followUp: String?
|
|
@@ -89,7 +94,25 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
89
94
|
func applicationDidFinishLaunching(_ note: Notification) {
|
|
90
95
|
NSApp.setActivationPolicy(.accessory)
|
|
91
96
|
retireOlderInstances()
|
|
92
|
-
panel.
|
|
97
|
+
panel.onRender = { [weak self] state in self?.statusMenu.show(state) }
|
|
98
|
+
panel.contextMenu = { [weak self] in self?.statusMenu.menu ?? NSMenu() }
|
|
99
|
+
panel.agentName = { [weak self] in
|
|
100
|
+
Config.effectiveAgentID.isEmpty ? "" : self?.statusMenu.name(of: Config.effectiveAgentID) ?? ""
|
|
101
|
+
}
|
|
102
|
+
panel.alwaysVisible = Config.showPill
|
|
103
|
+
panel.render(.idle)
|
|
104
|
+
statusMenu.onPillChanged = { [weak self] on in
|
|
105
|
+
guard let self else { return }
|
|
106
|
+
self.panel.alwaysVisible = on
|
|
107
|
+
if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
|
|
108
|
+
}
|
|
109
|
+
statusMenu.onTargetChanged = { [weak self] id in
|
|
110
|
+
Config.effectiveAgentID = id
|
|
111
|
+
// The last answer's voice belongs to the previous agent.
|
|
112
|
+
self?.voice = nil
|
|
113
|
+
Log.info("target agent: \(id)")
|
|
114
|
+
}
|
|
115
|
+
statusMenu.refresh()
|
|
93
116
|
|
|
94
117
|
hotkey = Hotkey(
|
|
95
118
|
id: 1,
|
|
@@ -102,7 +125,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
102
125
|
// becomes welcome. Without this the queue is a hole rather than a
|
|
103
126
|
// delay — the daemon's watcher only notices SYSTEM Focus ending,
|
|
104
127
|
// and this switch is not that.
|
|
105
|
-
|
|
128
|
+
statusMenu.onHoldChanged = { [weak self] nowOn in
|
|
106
129
|
guard let self else { return }
|
|
107
130
|
self.panel.render(.idle)
|
|
108
131
|
if !nowOn { Hold.flushHeld() }
|
|
@@ -120,7 +143,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
120
143
|
pasteHotkey?.register(keyCode: UInt32(kVK_ANSI_V),
|
|
121
144
|
modifiers: UInt32(cmdKey | optionKey))
|
|
122
145
|
|
|
123
|
-
// ⌘⌥. (and the
|
|
146
|
+
// ⌘⌥. (and the menu): stop every voice now. ⌘. is the Mac's
|
|
124
147
|
// own "cancel"; Option keeps it from reaching the app in front.
|
|
125
148
|
stopHotkey = Hotkey(
|
|
126
149
|
id: 3,
|
|
@@ -128,16 +151,28 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
128
151
|
onRelease: { [weak self] in self?.stopSpeaking() })
|
|
129
152
|
stopHotkey?.register(keyCode: UInt32(kVK_ANSI_Period),
|
|
130
153
|
modifiers: UInt32(cmdKey | optionKey))
|
|
131
|
-
|
|
154
|
+
statusMenu.onStop = { [weak self] in self?.stopSpeaking() }
|
|
155
|
+
|
|
156
|
+
// ⌘⌥A: open the menu-bar menu without the mouse; arrows, digits
|
|
157
|
+
// and Return work from there.
|
|
158
|
+
menuHotkey = Hotkey(
|
|
159
|
+
id: 4,
|
|
160
|
+
onPress: {},
|
|
161
|
+
onRelease: { [weak self] in self?.statusMenu.open() })
|
|
162
|
+
menuHotkey?.register(keyCode: UInt32(kVK_ANSI_A),
|
|
163
|
+
modifiers: UInt32(cmdKey | optionKey))
|
|
132
164
|
|
|
165
|
+
// The target does not wait for the microphone: the menu shows it
|
|
166
|
+
// either way.
|
|
167
|
+
Task { @MainActor in
|
|
168
|
+
Config.effectiveAgentID = await AgentClient.resolveAgent()
|
|
169
|
+
Log.info("agent: \(Config.effectiveAgentID.isEmpty ? "the daemon's default" : Config.effectiveAgentID)")
|
|
170
|
+
}
|
|
133
171
|
recorder.requestPermission { [weak self] granted in
|
|
134
172
|
Task { @MainActor in
|
|
135
173
|
guard let self else { return }
|
|
136
174
|
if !granted { self.panel.render(.error("Microphone denied")) }
|
|
137
|
-
else {
|
|
138
|
-
Config.effectiveAgentID = await AgentClient.resolveAgent()
|
|
139
|
-
Log.info("ready — hold ⌥Space to talk (agent: \(Config.effectiveAgentID.isEmpty ? "the daemon's default" : Config.effectiveAgentID))")
|
|
140
|
-
}
|
|
175
|
+
else { Log.info("ready — hold ⌥Space to talk") }
|
|
141
176
|
}
|
|
142
177
|
}
|
|
143
178
|
}
|
|
@@ -264,6 +299,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
264
299
|
// ignoring the key while busy is how Anis spoke to a lesson and
|
|
265
300
|
// nothing listened.
|
|
266
301
|
Speech.stop()
|
|
302
|
+
speaking?.cancel()
|
|
303
|
+
speaking = nil
|
|
267
304
|
lastSpokeAt = Date()
|
|
268
305
|
talkCheck = Task { await AgentClient.hush() }
|
|
269
306
|
Log.info("door: opened\(busy ? " (a turn is running)" : "")")
|
|
@@ -282,6 +319,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
282
319
|
let door = talkCheck ?? Task { await AgentClient.hush() }
|
|
283
320
|
talkCheck = nil
|
|
284
321
|
guard let wav = recorder.stop() else {
|
|
322
|
+
// Nothing said, so no door follows the hush: let the queue play on.
|
|
323
|
+
Task { _ = await door.value; await AgentClient.resume() }
|
|
285
324
|
panel.render(.error("Too short — hold while speaking"))
|
|
286
325
|
resetSoon()
|
|
287
326
|
return
|
|
@@ -295,19 +334,23 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
295
334
|
do { heard = try await Speech.transcribe(wav: wav) }
|
|
296
335
|
catch { Log.warn("transcription failed: \(error.localizedDescription)") }
|
|
297
336
|
guard !heard.isEmpty else {
|
|
337
|
+
_ = await door.value
|
|
338
|
+
await AgentClient.resume()
|
|
298
339
|
if !midTurn {
|
|
299
340
|
panel.render(.error("Didn't catch that"))
|
|
300
341
|
// Say it aloud: someone not looking would think it was sent.
|
|
301
|
-
await Speech.
|
|
342
|
+
await Speech.say("Sorry, I didn't hear that. Please say it again.", agentID: nil, kind: "line", voice: voice)
|
|
302
343
|
busy = false; resetSoon()
|
|
303
344
|
}
|
|
304
345
|
return
|
|
305
346
|
}
|
|
306
347
|
Log.info("heard: \(heard)")
|
|
307
348
|
let hushed = await door.value
|
|
308
|
-
|
|
349
|
+
// Always through the door, even with nothing hushed: the
|
|
350
|
+
// listener's turn is over, so the daemon's queue plays on.
|
|
351
|
+
if await AgentClient.door(heard) {
|
|
309
352
|
// The talk or lesson answers out loud through the daemon.
|
|
310
|
-
Log.info("door: \"\(heard)\" → \(hushed.kind
|
|
353
|
+
Log.info("door: \"\(heard)\" → \(hushed.kind ?? "?")\(hushed.agentID.map { " (\($0))" } ?? "")")
|
|
311
354
|
if !midTurn { panel.render(.idle); busy = false }
|
|
312
355
|
return
|
|
313
356
|
}
|
|
@@ -336,7 +379,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
336
379
|
do {
|
|
337
380
|
beginNarration()
|
|
338
381
|
|
|
339
|
-
let answer = try await AgentClient.ask(heard)
|
|
382
|
+
let answer = try await AgentClient.ask(heard, agent: Config.effectiveAgentID)
|
|
340
383
|
endNarration()
|
|
341
384
|
if abandoned {
|
|
342
385
|
abandoned = false
|
|
@@ -367,7 +410,11 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
367
410
|
// Scroll the sentence being spoken, so it can be read as
|
|
368
411
|
// well as heard — and re-read after, which speech cannot do.
|
|
369
412
|
panel.render(.saying(answer.text))
|
|
370
|
-
|
|
413
|
+
// Queued on the daemon behind whatever is already speaking.
|
|
414
|
+
let line = Task { await Speech.say(answer.text, agentID: answer.agentID, kind: "answer", voice: voice) }
|
|
415
|
+
speaking = line
|
|
416
|
+
await line.value
|
|
417
|
+
if speaking == line { speaking = nil }
|
|
371
418
|
// Cut off by the door: the new words are being recorded.
|
|
372
419
|
if recorder.isRecording { busy = false; return }
|
|
373
420
|
// Stopped: no follow-up window, the listener asked for quiet.
|
|
@@ -390,7 +437,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
390
437
|
panel.render(.error(short(error.localizedDescription)))
|
|
391
438
|
// Say it aloud too — a voice assistant that fails only in
|
|
392
439
|
// a 230px label has failed silently for anyone not looking.
|
|
393
|
-
await Speech.
|
|
440
|
+
await Speech.say("Sorry, that didn't work.", agentID: nil, kind: "line", voice: voice)
|
|
394
441
|
resetSoon()
|
|
395
442
|
}
|
|
396
443
|
busy = false
|
|
@@ -465,7 +512,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
465
512
|
tool: tool, detail: detail, elapsed: Int(elapsed)) else { return }
|
|
466
513
|
guard !self.spokenSteps.contains(phrase) else { return }
|
|
467
514
|
self.spokenSteps.insert(phrase)
|
|
468
|
-
await Speech.
|
|
515
|
+
await Speech.say(phrase.prefix(1).capitalized + phrase.dropFirst() + ".", agentID: nil, kind: "narration", voice: self.voice)
|
|
469
516
|
}
|
|
470
517
|
}
|
|
471
518
|
|