agentix-cli 0.26.0 → 0.28.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/README.md +67 -118
- package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
- package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
- package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
- package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
- package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
- package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
- package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
- package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
- package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
- package/apps/mac-helper/build.sh +39 -0
- package/apps/mac-voice/Resources/Info.plist +18 -0
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
- package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
- package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
- package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
- package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
- package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
- package/apps/mac-voice/build.sh +32 -0
- package/dist/{App-BPNHIGUX.js → App-CKPH3HHF.js} +2 -2
- package/dist/{ChatApp-6XSSZB4U.js → ChatApp-MX7IG4K2.js} +2 -2
- package/dist/agent-2U574KBZ.js +2 -0
- package/dist/article-fields-L4D2BOGY.js +2 -0
- package/dist/article-quality-44ONVGLN.js +3 -0
- package/dist/article-quality-44ONVGLN.js.map +1 -0
- package/dist/{audit-JU3HWISB.js → audit-4245CX6P.js} +2 -2
- package/dist/backfill-MTD2B62I.js +6 -0
- package/dist/backfill-MTD2B62I.js.map +1 -0
- package/dist/{board-dashboard-M3372FLO.js → board-dashboard-QR7CVTKA.js} +1411 -453
- package/dist/board-dashboard-QR7CVTKA.js.map +1 -0
- package/dist/{builtin-FRKAQMFS.js → builtin-B67DMJ3M.js} +2 -2
- package/dist/calibration-ILS5OI44.js +2 -0
- package/dist/{chunk-V3U4OF7L.js → chunk-3LYM22IV.js} +46 -48
- package/dist/chunk-3LYM22IV.js.map +1 -0
- package/dist/chunk-3SCPRYJB.js +5 -0
- package/dist/chunk-3SCPRYJB.js.map +1 -0
- package/dist/chunk-4BR7D2OU.js +36 -0
- package/dist/chunk-4BR7D2OU.js.map +1 -0
- package/dist/{chunk-P4VWIVKN.js → chunk-52ZPELD2.js} +403 -29
- package/dist/chunk-52ZPELD2.js.map +1 -0
- package/dist/chunk-5IA2CYHN.js +5 -0
- package/dist/{chunk-O6YPEOQF.js.map → chunk-5IA2CYHN.js.map} +1 -1
- package/dist/chunk-64CAIB3C.js +9 -0
- package/dist/chunk-64CAIB3C.js.map +1 -0
- package/dist/{chunk-5U35WAZF.js → chunk-77OV7346.js} +6 -6
- package/dist/chunk-77OV7346.js.map +1 -0
- package/dist/chunk-AJ5RMU7X.js +2 -0
- package/dist/chunk-AJ5RMU7X.js.map +1 -0
- package/dist/chunk-AJONZWLT.js +2 -0
- package/dist/chunk-AJONZWLT.js.map +1 -0
- package/dist/chunk-BH3GTCZ6.js +2 -0
- package/dist/chunk-BH3GTCZ6.js.map +1 -0
- package/dist/chunk-E3UJHQGN.js +1189 -0
- package/dist/chunk-E3UJHQGN.js.map +1 -0
- package/dist/chunk-EVWEG3VD.js +3 -0
- package/dist/chunk-EVWEG3VD.js.map +1 -0
- package/dist/chunk-F4HZTZHW.js +2 -0
- package/dist/chunk-F4HZTZHW.js.map +1 -0
- package/dist/chunk-F6CNF7V3.js +25 -0
- package/dist/chunk-F6CNF7V3.js.map +1 -0
- package/dist/chunk-FIOB3MEX.js +2 -0
- package/dist/{chunk-H5D7ATXS.js → chunk-FKBGJYWQ.js} +5 -5
- package/dist/chunk-FKBGJYWQ.js.map +1 -0
- package/dist/{chunk-BVGJSPW4.js → chunk-G2HMRDUQ.js} +7 -7
- package/dist/chunk-G2HMRDUQ.js.map +1 -0
- package/dist/chunk-H2QQU2XV.js +109 -0
- package/dist/chunk-H2QQU2XV.js.map +1 -0
- package/dist/{chunk-LDHPMJ7M.js → chunk-IWMW5432.js} +3 -3
- package/dist/chunk-IWMW5432.js.map +1 -0
- package/dist/chunk-K33GIBSE.js +2 -0
- package/dist/chunk-K33GIBSE.js.map +1 -0
- package/dist/chunk-L2WSPY6S.js +2 -0
- package/dist/chunk-L2WSPY6S.js.map +1 -0
- package/dist/chunk-LCIREYTD.js +54 -0
- package/dist/chunk-LCIREYTD.js.map +1 -0
- package/dist/chunk-MHIMGM4G.js +126 -0
- package/dist/chunk-MHIMGM4G.js.map +1 -0
- package/dist/chunk-P5ZBBEPL.js +13 -0
- package/dist/chunk-P5ZBBEPL.js.map +1 -0
- package/dist/{chunk-JTJIPNXY.js → chunk-PNWNVOEZ.js} +1 -1
- package/dist/chunk-PNWNVOEZ.js.map +1 -0
- package/dist/chunk-QHTQPBHU.js +45 -0
- package/dist/chunk-QHTQPBHU.js.map +1 -0
- package/dist/chunk-S2MNL5V6.js +2 -0
- package/dist/chunk-S2MNL5V6.js.map +1 -0
- package/dist/chunk-SCTIUFL4.js +5 -0
- package/dist/{chunk-K2CAPYES.js.map → chunk-SCTIUFL4.js.map} +1 -1
- package/dist/chunk-ULLAE6LE.js +157 -0
- package/dist/chunk-ULLAE6LE.js.map +1 -0
- package/dist/{chunk-DJEZ2HVU.js → chunk-VDMYBPYB.js} +1 -1
- package/dist/chunk-VDMYBPYB.js.map +1 -0
- package/dist/chunk-VIH7ARPF.js +547 -0
- package/dist/chunk-VIH7ARPF.js.map +1 -0
- package/dist/chunk-W5C2GDNC.js +32 -0
- package/dist/{chunk-JP5IEXR7.js.map → chunk-W5C2GDNC.js.map} +1 -1
- package/dist/chunk-WPMX3VGI.js +3 -0
- package/dist/chunk-WPMX3VGI.js.map +1 -0
- package/dist/chunk-YLBERT7N.js +24 -0
- package/dist/chunk-YLBERT7N.js.map +1 -0
- package/dist/chunk-YLGIZRHV.js +114 -0
- package/dist/chunk-YLGIZRHV.js.map +1 -0
- package/dist/chunk-ZMYGXHPT.js +121 -0
- package/dist/chunk-ZMYGXHPT.js.map +1 -0
- package/dist/chunk-ZTEU5JVQ.js +2 -0
- package/dist/chunk-ZTEU5JVQ.js.map +1 -0
- package/dist/{chunk-2WVX3LEC.js → chunk-ZVP6GB4G.js} +1 -1
- package/dist/chunk-ZVP6GB4G.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-ZLSXGPTV.js +2 -0
- package/dist/{compaction-NWEMOSHA.js → compaction-FD4R23HZ.js} +4 -4
- package/dist/config-HMW3EBZL.js +2 -0
- package/dist/config-mutator-4SZ5YEVD.js +2 -0
- package/dist/{conflict-detector-D7VIYPDI.js → conflict-detector-ZPOT3M2C.js} +2 -2
- package/dist/conflict-detector-ZPOT3M2C.js.map +1 -0
- package/dist/context-planner-AQHJMVY2.js +17 -0
- package/dist/context-planner-AQHJMVY2.js.map +1 -0
- package/dist/debug-3MEUO2P2.js +2 -0
- package/dist/{discover-XJXQXXIO.js → discover-D4VVRNAI.js} +2 -2
- package/dist/discover-D4VVRNAI.js.map +1 -0
- package/dist/doctor-LRYV3QLK.js +2 -0
- package/dist/editor-chat-OB7IQOB5.js +137 -0
- package/dist/editor-chat-OB7IQOB5.js.map +1 -0
- package/dist/entry-triage-LK34TSQD.js +2 -0
- package/dist/entry-triage-LK34TSQD.js.map +1 -0
- package/dist/facts-YVEJOM7O.js +22 -0
- package/dist/facts-YVEJOM7O.js.map +1 -0
- package/dist/heal-FVLBHYP7.js +2 -0
- package/dist/index.d.ts +1040 -64
- package/dist/index.js +12 -19
- package/dist/index.js.map +1 -1
- package/dist/{ingest-whatsapp-LZE62INA.js → ingest-whatsapp-NS4OWDOU.js} +2 -2
- package/dist/loader-4ZGTK7LD.js +2 -0
- package/dist/loader-BOKE5M5S.js +2 -0
- package/dist/memory-extract-F5ZHWY5E.js +2 -0
- package/dist/{migrate-v2-QO7SLKIM.js → migrate-v2-V4WN2VVM.js} +2 -2
- package/dist/ntfy-X7EPB3TG.js +4 -0
- package/dist/ntfy-X7EPB3TG.js.map +1 -0
- package/dist/processes-WU23U2OJ.js +2 -0
- package/dist/program-GSZ5AYAM.js +2 -0
- package/dist/{projects-api-KRME2YKZ.js → projects-api-QE6L65SK.js} +2 -2
- package/dist/{projects-mutate-3NHVG437.js → projects-mutate-MY7JTLSZ.js} +2 -2
- package/dist/providers-KO2DODIO.js +2 -0
- package/dist/query-SQDHE4HV.js +2 -0
- package/dist/questions-4B4KSPXG.js +3 -0
- package/dist/questions-4B4KSPXG.js.map +1 -0
- package/dist/recipes-7S4MC5OJ.js +2 -0
- package/dist/registry-LE6FWYZC.js +2 -0
- package/dist/review-candidates-RQU5KNZM.js +4 -0
- package/dist/review-candidates-RQU5KNZM.js.map +1 -0
- package/dist/review-store-B4YE3EFK.js +4 -0
- package/dist/review-store-B4YE3EFK.js.map +1 -0
- package/dist/{rotation-memo-B3EXXSEH.js → rotation-memo-7T75BLEX.js} +2 -2
- package/dist/routing-ILERJXB6.js +2 -0
- package/dist/runner-YRBVNYTY.js +2 -0
- package/dist/scheduler-BFM3EINS.js +2 -0
- package/dist/seat-7WM7II2B.js +2 -0
- package/dist/select-X4KVMOP5.js +2 -0
- package/dist/select-X4KVMOP5.js.map +1 -0
- package/dist/store-MCOA6N4F.js +2 -0
- package/dist/{subagent-WV7QKQ3L.js → subagent-2SJHSSJS.js} +2 -2
- package/dist/{surface-usage-PRL6Z4U3.js → surface-usage-NS74AXSE.js} +2 -2
- package/dist/timers-PCB4ZIBD.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js.map +1 -0
- package/dist/types-H5ENIUIT.js +2 -0
- package/dist/types-OIFU3KSQ.js +2 -0
- package/dist/types-OIFU3KSQ.js.map +1 -0
- package/dist/web/workflow-editor.global.js +1542 -0
- package/dist/web/workflow-editor.global.js.map +1 -0
- package/dist/{whatsapp-state-XMTCSRXU.js → whatsapp-state-6D6FHHD2.js} +2 -2
- package/dist/wiki-2AUFSIKO.js +2 -0
- package/dist/wiki-2AUFSIKO.js.map +1 -0
- package/dist/workflows/templates/branching.yaml +1 -1
- package/dist/{workspace-setup-TBQAHYHJ.js → workspace-setup-B3F3TUDE.js} +2 -2
- package/dist/workspace-setup-B3F3TUDE.js.map +1 -0
- package/package.json +14 -3
- package/scripts/postinstall.mjs +80 -0
- package/dist/agent-5QMITZT6.js +0 -2
- package/dist/architect-SEPWMWS4.js +0 -2
- package/dist/board-dashboard-M3372FLO.js.map +0 -1
- package/dist/chunk-2WVX3LEC.js.map +0 -1
- package/dist/chunk-3WQFCIB6.js +0 -24
- package/dist/chunk-3WQFCIB6.js.map +0 -1
- package/dist/chunk-4YCH6IZV.js +0 -2
- package/dist/chunk-53NRX2NA.js +0 -2
- package/dist/chunk-53NRX2NA.js.map +0 -1
- package/dist/chunk-5U35WAZF.js.map +0 -1
- package/dist/chunk-BFXONUUN.js +0 -108
- package/dist/chunk-BFXONUUN.js.map +0 -1
- package/dist/chunk-BFZUG66X.js +0 -45
- package/dist/chunk-BFZUG66X.js.map +0 -1
- package/dist/chunk-BTHHSY7D.js +0 -13
- package/dist/chunk-BTHHSY7D.js.map +0 -1
- package/dist/chunk-BVGJSPW4.js.map +0 -1
- package/dist/chunk-D46VZ7K7.js +0 -116
- package/dist/chunk-D46VZ7K7.js.map +0 -1
- package/dist/chunk-DJEZ2HVU.js.map +0 -1
- package/dist/chunk-E5CXCI33.js +0 -155
- package/dist/chunk-E5CXCI33.js.map +0 -1
- package/dist/chunk-FDWOECJY.js +0 -54
- package/dist/chunk-FDWOECJY.js.map +0 -1
- package/dist/chunk-H5D7ATXS.js.map +0 -1
- package/dist/chunk-HR4JSRZS.js +0 -3
- package/dist/chunk-HR4JSRZS.js.map +0 -1
- package/dist/chunk-ISEBLHWX.js +0 -118
- package/dist/chunk-ISEBLHWX.js.map +0 -1
- package/dist/chunk-JP5IEXR7.js +0 -32
- package/dist/chunk-JTJIPNXY.js.map +0 -1
- package/dist/chunk-K2CAPYES.js +0 -5
- package/dist/chunk-LDHPMJ7M.js.map +0 -1
- package/dist/chunk-METYYZKQ.js +0 -1115
- package/dist/chunk-METYYZKQ.js.map +0 -1
- package/dist/chunk-MTAPOCGA.js +0 -47
- package/dist/chunk-MTAPOCGA.js.map +0 -1
- package/dist/chunk-O6YPEOQF.js +0 -5
- package/dist/chunk-P4VWIVKN.js.map +0 -1
- package/dist/chunk-SR3X25TN.js +0 -3
- package/dist/chunk-SR3X25TN.js.map +0 -1
- package/dist/chunk-UHE5FCPB.js +0 -431
- package/dist/chunk-UHE5FCPB.js.map +0 -1
- package/dist/chunk-V3U4OF7L.js.map +0 -1
- package/dist/chunk-VJUTQAPJ.js +0 -2
- package/dist/chunk-VJUTQAPJ.js.map +0 -1
- package/dist/chunk-VT5XYFDD.js +0 -126
- package/dist/chunk-VT5XYFDD.js.map +0 -1
- package/dist/client-HGKAC6VP.js +0 -2
- package/dist/config-A4RWEJHV.js +0 -2
- package/dist/config-mutator-MNSFUN7D.js +0 -2
- package/dist/conflict-detector-D7VIYPDI.js.map +0 -1
- package/dist/context-planner-45GEWKGL.js +0 -17
- package/dist/context-planner-45GEWKGL.js.map +0 -1
- package/dist/debug-N3B5NVJU.js +0 -2
- package/dist/discover-XJXQXXIO.js.map +0 -1
- package/dist/doctor-3Q36ZFE6.js +0 -2
- package/dist/heal-HZ3EQD52.js +0 -2
- package/dist/loader-5CA3O5GM.js +0 -2
- package/dist/loader-6IHUCPVO.js +0 -2
- package/dist/memory-extract-AENFVVO5.js +0 -2
- package/dist/processes-UIC4XXA6.js +0 -2
- package/dist/program-SAUNRCCD.js +0 -2
- package/dist/providers-L27BJ5A6.js +0 -2
- package/dist/query-HZYYRCLU.js +0 -2
- package/dist/recipes-EPLE2SNT.js +0 -2
- package/dist/registry-HK3WQ7BS.js +0 -2
- package/dist/runner-BFYDWPUO.js +0 -2
- package/dist/scheduler-6T2SU5ST.js +0 -2
- package/dist/store-I3HEWVLQ.js +0 -2
- package/dist/timers-5LPZN55J.js +0 -2
- package/dist/types-VECTMYTO.js +0 -2
- package/dist/types-Y6ZCZ52Z.js +0 -2
- package/dist/wiki-M6YNRFC4.js +0 -2
- /package/dist/{App-BPNHIGUX.js.map → App-CKPH3HHF.js.map} +0 -0
- /package/dist/{ChatApp-6XSSZB4U.js.map → ChatApp-MX7IG4K2.js.map} +0 -0
- /package/dist/{agent-5QMITZT6.js.map → agent-2U574KBZ.js.map} +0 -0
- /package/dist/{architect-SEPWMWS4.js.map → article-fields-L4D2BOGY.js.map} +0 -0
- /package/dist/{audit-JU3HWISB.js.map → audit-4245CX6P.js.map} +0 -0
- /package/dist/{builtin-FRKAQMFS.js.map → builtin-B67DMJ3M.js.map} +0 -0
- /package/dist/{chunk-4YCH6IZV.js.map → calibration-ILS5OI44.js.map} +0 -0
- /package/dist/{client-HGKAC6VP.js.map → chunk-FIOB3MEX.js.map} +0 -0
- /package/dist/{config-A4RWEJHV.js.map → client-ZLSXGPTV.js.map} +0 -0
- /package/dist/{compaction-NWEMOSHA.js.map → compaction-FD4R23HZ.js.map} +0 -0
- /package/dist/{config-mutator-MNSFUN7D.js.map → config-HMW3EBZL.js.map} +0 -0
- /package/dist/{debug-N3B5NVJU.js.map → config-mutator-4SZ5YEVD.js.map} +0 -0
- /package/dist/{doctor-3Q36ZFE6.js.map → debug-3MEUO2P2.js.map} +0 -0
- /package/dist/{heal-HZ3EQD52.js.map → doctor-LRYV3QLK.js.map} +0 -0
- /package/dist/{loader-5CA3O5GM.js.map → heal-FVLBHYP7.js.map} +0 -0
- /package/dist/{ingest-whatsapp-LZE62INA.js.map → ingest-whatsapp-NS4OWDOU.js.map} +0 -0
- /package/dist/{loader-6IHUCPVO.js.map → loader-4ZGTK7LD.js.map} +0 -0
- /package/dist/{memory-extract-AENFVVO5.js.map → loader-BOKE5M5S.js.map} +0 -0
- /package/dist/{processes-UIC4XXA6.js.map → memory-extract-F5ZHWY5E.js.map} +0 -0
- /package/dist/{migrate-v2-QO7SLKIM.js.map → migrate-v2-V4WN2VVM.js.map} +0 -0
- /package/dist/{program-SAUNRCCD.js.map → processes-WU23U2OJ.js.map} +0 -0
- /package/dist/{providers-L27BJ5A6.js.map → program-GSZ5AYAM.js.map} +0 -0
- /package/dist/{projects-api-KRME2YKZ.js.map → projects-api-QE6L65SK.js.map} +0 -0
- /package/dist/{projects-mutate-3NHVG437.js.map → projects-mutate-MY7JTLSZ.js.map} +0 -0
- /package/dist/{query-HZYYRCLU.js.map → providers-KO2DODIO.js.map} +0 -0
- /package/dist/{recipes-EPLE2SNT.js.map → query-SQDHE4HV.js.map} +0 -0
- /package/dist/{registry-HK3WQ7BS.js.map → recipes-7S4MC5OJ.js.map} +0 -0
- /package/dist/{runner-BFYDWPUO.js.map → registry-LE6FWYZC.js.map} +0 -0
- /package/dist/{rotation-memo-B3EXXSEH.js.map → rotation-memo-7T75BLEX.js.map} +0 -0
- /package/dist/{scheduler-6T2SU5ST.js.map → routing-ILERJXB6.js.map} +0 -0
- /package/dist/{store-I3HEWVLQ.js.map → runner-YRBVNYTY.js.map} +0 -0
- /package/dist/{surface-usage-PRL6Z4U3.js.map → scheduler-BFM3EINS.js.map} +0 -0
- /package/dist/{timers-5LPZN55J.js.map → seat-7WM7II2B.js.map} +0 -0
- /package/dist/{types-VECTMYTO.js.map → store-MCOA6N4F.js.map} +0 -0
- /package/dist/{subagent-WV7QKQ3L.js.map → subagent-2SJHSSJS.js.map} +0 -0
- /package/dist/{types-Y6ZCZ52Z.js.map → surface-usage-NS74AXSE.js.map} +0 -0
- /package/dist/{wiki-M6YNRFC4.js.map → timers-PCB4ZIBD.js.map} +0 -0
- /package/dist/{workspace-setup-TBQAHYHJ.js.map → types-H5ENIUIT.js.map} +0 -0
- /package/dist/{whatsapp-state-XMTCSRXU.js.map → whatsapp-state-6D6FHHD2.js.map} +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// What the agent said, in a form you can read, click and copy.
|
|
5
|
+
///
|
|
6
|
+
/// Speech is lossy in one specific way that matters: a URL cannot be
|
|
7
|
+
/// spoken usefully, an image cannot be spoken at all, and a name you have
|
|
8
|
+
/// not seen written is a name you cannot act on. The spoken answer is
|
|
9
|
+
/// deliberately short and stripped of exactly those things — so without a
|
|
10
|
+
/// visual surface, everything worth keeping from a turn is the part that
|
|
11
|
+
/// gets thrown away.
|
|
12
|
+
///
|
|
13
|
+
/// Rich content arrives through the same `agentx:ui` directive Telegram
|
|
14
|
+
/// and WhatsApp already use, parsed server-side, so an agent has one way to
|
|
15
|
+
/// attach a link or a picture regardless of where it is speaking.
|
|
16
|
+
final class ResultCard: NSPanel {
|
|
17
|
+
// A web view, because agents write markdown — headings, bullets,
|
|
18
|
+
// fenced code, tables. An NSTextView shows that as a mess of asterisks
|
|
19
|
+
// and hashes, which defeats the card's only purpose: being MORE
|
|
20
|
+
// readable than the spoken answer, not less.
|
|
21
|
+
private let web = WKWebView()
|
|
22
|
+
private let links = NSStackView()
|
|
23
|
+
private let thumb = NSImageView()
|
|
24
|
+
|
|
25
|
+
init() {
|
|
26
|
+
super.init(contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
|
|
27
|
+
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
|
|
28
|
+
backing: .buffered, defer: false)
|
|
29
|
+
title = "Agent"
|
|
30
|
+
// Brand radius on the utility panel so it matches the pill it
|
|
31
|
+
// sits above rather than defaulting to the system's corner.
|
|
32
|
+
contentView?.wantsLayer = true
|
|
33
|
+
isFloatingPanel = true
|
|
34
|
+
level = .floating
|
|
35
|
+
hidesOnDeactivate = false
|
|
36
|
+
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
37
|
+
|
|
38
|
+
let content = NSView(frame: contentRect(forFrameRect: frame))
|
|
39
|
+
content.autoresizingMask = [.width, .height]
|
|
40
|
+
|
|
41
|
+
// Selectable, so the whole point — copying a link or a name out —
|
|
42
|
+
// actually works. Editable would let a stray keystroke destroy the
|
|
43
|
+
// answer before it is read.
|
|
44
|
+
web.setValue(false, forKey: "drawsBackground")
|
|
45
|
+
web.navigationDelegate = self
|
|
46
|
+
web.autoresizingMask = [.width, .height]
|
|
47
|
+
content.addSubview(web)
|
|
48
|
+
|
|
49
|
+
thumb.imageScaling = .scaleProportionallyUpOrDown
|
|
50
|
+
thumb.isHidden = true
|
|
51
|
+
content.addSubview(thumb)
|
|
52
|
+
|
|
53
|
+
links.orientation = .horizontal
|
|
54
|
+
links.spacing = 8
|
|
55
|
+
links.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
|
56
|
+
content.addSubview(links)
|
|
57
|
+
|
|
58
|
+
contentView = content
|
|
59
|
+
layout(content)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func layout(_ content: NSView) {
|
|
63
|
+
let w = content.bounds.width, h = content.bounds.height
|
|
64
|
+
let linkH: CGFloat = links.arrangedSubviews.isEmpty ? 0 : 34
|
|
65
|
+
let imgH: CGFloat = thumb.isHidden ? 0 : 120
|
|
66
|
+
thumb.frame = NSRect(x: 10, y: h - imgH - 10, width: w - 20, height: imgH)
|
|
67
|
+
web.frame = NSRect(x: 0, y: linkH, width: w, height: h - linkH - imgH - (imgH > 0 ? 16 : 0))
|
|
68
|
+
links.frame = NSRect(x: 0, y: 0, width: w, height: linkH)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Does this turn have anything the spoken answer did not already
|
|
72
|
+
/// deliver?
|
|
73
|
+
///
|
|
74
|
+
/// The card opened on every turn, including "Ok." — a window with one
|
|
75
|
+
/// word in it, appearing and demanding dismissal, for an answer you had
|
|
76
|
+
/// already heard. A surface that adds nothing is worse than no surface:
|
|
77
|
+
/// it trains you to ignore it, so the turn where it DOES carry a link
|
|
78
|
+
/// gets ignored too.
|
|
79
|
+
///
|
|
80
|
+
/// Worth showing when there is rich content, a URL you could click, or
|
|
81
|
+
/// materially more written than was spoken. Otherwise the speech was
|
|
82
|
+
/// the whole answer.
|
|
83
|
+
static func isWorthShowing(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) -> Bool {
|
|
84
|
+
if !buttons.isEmpty || imageURL != nil { return true }
|
|
85
|
+
let text = (written?.isEmpty == false ? written! : spoken)
|
|
86
|
+
if text.range(of: #"https?://"#, options: .regularExpression) != nil { return true }
|
|
87
|
+
// Speech drops formatting, so written is often a little longer for
|
|
88
|
+
// the same content. Only a real difference counts.
|
|
89
|
+
let spokenLen = spoken.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
90
|
+
let writtenLen = text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
91
|
+
if writtenLen > spokenLen + 80 { return true }
|
|
92
|
+
// Long enough that re-reading it beats re-hearing it.
|
|
93
|
+
return writtenLen > 280
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// `spoken` is what was said; `written` is the fuller answer with the
|
|
97
|
+
/// URLs and formatting the spoken form had to drop.
|
|
98
|
+
@MainActor
|
|
99
|
+
func show(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
|
|
100
|
+
let source = (written?.isEmpty == false ? written! : spoken)
|
|
101
|
+
web.loadHTMLString(Markdown.page(Markdown.toHTML(source), css: Brand.cardCSS), baseURL: nil)
|
|
102
|
+
|
|
103
|
+
links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
|
|
104
|
+
for (label, url) in buttons.prefix(4) {
|
|
105
|
+
let b = NSButton(title: label, target: self, action: #selector(openLink(_:)))
|
|
106
|
+
b.bezelStyle = .rounded
|
|
107
|
+
b.toolTip = url
|
|
108
|
+
b.identifier = NSUserInterfaceItemIdentifier(url)
|
|
109
|
+
links.addArrangedSubview(b)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
thumb.isHidden = true
|
|
113
|
+
if let s = imageURL, let u = URL(string: s) {
|
|
114
|
+
// Off the main thread: a slow image must never stall the UI,
|
|
115
|
+
// and the card is useful without it.
|
|
116
|
+
URLSession.shared.dataTask(with: u) { [weak self] data, _, _ in
|
|
117
|
+
guard let data, let img = NSImage(data: data) else { return }
|
|
118
|
+
Task { @MainActor in
|
|
119
|
+
guard let self else { return }
|
|
120
|
+
self.thumb.image = img
|
|
121
|
+
self.thumb.isHidden = false
|
|
122
|
+
if let c = self.contentView { self.layout(c) }
|
|
123
|
+
}
|
|
124
|
+
}.resume()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if let c = contentView { layout(c) }
|
|
128
|
+
positionAboveWidget()
|
|
129
|
+
orderFrontRegardless()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@objc private func openLink(_ sender: NSButton) {
|
|
133
|
+
guard let s = sender.identifier?.rawValue, let u = URL(string: s) else { return }
|
|
134
|
+
NSWorkspace.shared.open(u)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Sits just above the pill so the two read as one thing.
|
|
138
|
+
private func positionAboveWidget() {
|
|
139
|
+
guard let screen = NSScreen.main else { return }
|
|
140
|
+
let v = screen.visibleFrame
|
|
141
|
+
setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24 + 54 + 10))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override var canBecomeKey: Bool { true }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
extension ResultCard: WKNavigationDelegate {
|
|
148
|
+
/// Links open in the real browser. A 380-point utility panel is not a
|
|
149
|
+
/// place to read a web page, and navigating away would replace the
|
|
150
|
+
/// answer the card exists to hold.
|
|
151
|
+
func webView(_ webView: WKWebView,
|
|
152
|
+
decidePolicyFor navigationAction: WKNavigationAction,
|
|
153
|
+
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
154
|
+
if navigationAction.navigationType == .linkActivated,
|
|
155
|
+
let url = navigationAction.request.url {
|
|
156
|
+
NSWorkspace.shared.open(url)
|
|
157
|
+
decisionHandler(.cancel)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
decisionHandler(.allow)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// ⌘⌥V — paste the clipboard in the shape the destination wants.
|
|
4
|
+
///
|
|
5
|
+
/// The decision, the transforms and the safety checks all live in agentx
|
|
6
|
+
/// (`agentx paste`), not here. Same split as pointing and looking: Swift
|
|
7
|
+
/// owns the hotkey and the panel, and the capability itself belongs to
|
|
8
|
+
/// every agent rather than to this one app. The widget is a trigger.
|
|
9
|
+
///
|
|
10
|
+
/// Resolved through a LOGIN SHELL rather than an absolute path. A GUI app
|
|
11
|
+
/// launched from Finder inherits almost no environment, so a hardcoded
|
|
12
|
+
/// path is the difference between working from a terminal and failing
|
|
13
|
+
/// mysteriously on double-click — and the path would differ per machine
|
|
14
|
+
/// anyway. `AGENTX_PASTE_COMMAND` overrides it.
|
|
15
|
+
enum SmartPaste {
|
|
16
|
+
|
|
17
|
+
struct Result {
|
|
18
|
+
let applied: String
|
|
19
|
+
let reason: String
|
|
20
|
+
let changed: Bool
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Runs off the main thread: a login shell costs ~100ms to start, and
|
|
24
|
+
/// blocking the main thread would freeze the panel mid-keystroke.
|
|
25
|
+
static func run(completion: @escaping (Result?) -> Void) {
|
|
26
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
27
|
+
let result = execute()
|
|
28
|
+
DispatchQueue.main.async { completion(result) }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private static func execute() -> Result? {
|
|
33
|
+
let process = Process()
|
|
34
|
+
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
35
|
+
// -l so PATH includes the places node and the CLI actually live.
|
|
36
|
+
process.arguments = ["-lc", Config.pasteCommand + " --json"]
|
|
37
|
+
|
|
38
|
+
let pipe = Pipe()
|
|
39
|
+
process.standardOutput = pipe
|
|
40
|
+
process.standardError = Pipe()
|
|
41
|
+
|
|
42
|
+
do {
|
|
43
|
+
try process.run()
|
|
44
|
+
} catch {
|
|
45
|
+
Log.info("smart paste: could not start (\(error.localizedDescription))")
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read before waiting: a full pipe buffer with nobody draining it
|
|
50
|
+
// deadlocks the child, and this one prints the whole clipboard.
|
|
51
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
52
|
+
process.waitUntilExit()
|
|
53
|
+
|
|
54
|
+
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
55
|
+
Log.info("smart paste: no JSON from `\(Config.pasteCommand)`")
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
return Result(
|
|
59
|
+
applied: object["applied"] as? String ?? "asIs",
|
|
60
|
+
reason: object["reason"] as? String ?? "",
|
|
61
|
+
changed: object["changed"] as? Bool ?? false,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// What to show after a paste. Deliberately quiet when nothing changed:
|
|
66
|
+
/// the common case is "pasted as copied", and announcing that every
|
|
67
|
+
/// time would make the feature feel like it is in the way.
|
|
68
|
+
static func summary(_ result: Result) -> String? {
|
|
69
|
+
guard result.changed else { return nil }
|
|
70
|
+
return result.applied
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Speech in and out, ElevenLabs first with a local fallback on each side.
|
|
5
|
+
///
|
|
6
|
+
/// The fallbacks are not decoration. This widget is push-to-talk on a
|
|
7
|
+
/// laptop: it will be used on hotel wifi, on a plane, and on the day the
|
|
8
|
+
/// key expires. A voice assistant that answers "network error" out loud is
|
|
9
|
+
/// worse than one that answers in a robot voice, so STT degrades to
|
|
10
|
+
/// on-device mlx-whisper and TTS degrades to `say`.
|
|
11
|
+
enum Speech {
|
|
12
|
+
// MARK: Speech to text
|
|
13
|
+
|
|
14
|
+
static func transcribe(wav: Data) async throws -> String {
|
|
15
|
+
if let key = Config.elevenLabsKey {
|
|
16
|
+
do { return try await elevenLabsSTT(wav: wav, key: key) }
|
|
17
|
+
catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to mlx-whisper") }
|
|
18
|
+
}
|
|
19
|
+
return try await mlxWhisper(wav: wav)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private static func elevenLabsSTT(wav: Data, key: String) async throws -> String {
|
|
23
|
+
var req = URLRequest(url: URL(string: "https://api.elevenlabs.io/v1/speech-to-text")!)
|
|
24
|
+
req.httpMethod = "POST"
|
|
25
|
+
req.timeoutInterval = 60
|
|
26
|
+
req.setValue(key, forHTTPHeaderField: "xi-api-key")
|
|
27
|
+
|
|
28
|
+
let boundary = "agentx-\(UUID().uuidString)"
|
|
29
|
+
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
30
|
+
var body = Data()
|
|
31
|
+
func field(_ name: String, _ value: String) {
|
|
32
|
+
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n".data(using: .utf8)!)
|
|
33
|
+
}
|
|
34
|
+
field("model_id", Config.sttModel)
|
|
35
|
+
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"speech.wav\"\r\nContent-Type: audio/wav\r\n\r\n".data(using: .utf8)!)
|
|
36
|
+
body.append(wav)
|
|
37
|
+
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
|
38
|
+
req.httpBody = body
|
|
39
|
+
|
|
40
|
+
let (data, response) = try await URLSession.shared.data(for: req)
|
|
41
|
+
try check(response, data, "speech-to-text")
|
|
42
|
+
struct Reply: Decodable { let text: String? }
|
|
43
|
+
guard let text = (try? JSONDecoder().decode(Reply.self, from: data))?.text else {
|
|
44
|
+
throw VoiceError.api("speech-to-text returned no text")
|
|
45
|
+
}
|
|
46
|
+
return text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// On-device fallback. Writes the WAV to a temp file because
|
|
50
|
+
/// mlx_whisper is a CLI that takes a path.
|
|
51
|
+
private static func mlxWhisper(wav: Data) async throws -> String {
|
|
52
|
+
guard FileManager.default.isExecutableFile(atPath: Config.mlxWhisper) else {
|
|
53
|
+
throw VoiceError.api("no ElevenLabs key and mlx_whisper not found at \(Config.mlxWhisper)")
|
|
54
|
+
}
|
|
55
|
+
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
56
|
+
let audio = dir.appendingPathComponent("agentx-voice-\(UUID().uuidString).wav")
|
|
57
|
+
try wav.write(to: audio)
|
|
58
|
+
defer { try? FileManager.default.removeItem(at: audio) }
|
|
59
|
+
|
|
60
|
+
let out = try run(Config.mlxWhisper,
|
|
61
|
+
["--model", Config.mlxModel, "--output-format", "txt",
|
|
62
|
+
"--output-dir", dir.path, audio.path])
|
|
63
|
+
// mlx_whisper writes <name>.txt next to the audio; prefer that over
|
|
64
|
+
// stdout, which also carries progress noise.
|
|
65
|
+
let txt = audio.deletingPathExtension().appendingPathExtension("txt")
|
|
66
|
+
defer { try? FileManager.default.removeItem(at: txt) }
|
|
67
|
+
if let s = try? String(contentsOf: txt, encoding: .utf8) {
|
|
68
|
+
return s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
69
|
+
}
|
|
70
|
+
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: Text to speech
|
|
74
|
+
|
|
75
|
+
static func speak(_ text: String) async {
|
|
76
|
+
guard !text.isEmpty else { return }
|
|
77
|
+
if let key = Config.elevenLabsKey {
|
|
78
|
+
do {
|
|
79
|
+
let mp3 = try await elevenLabsTTS(text: text, key: key)
|
|
80
|
+
try await Player.shared.play(mp3)
|
|
81
|
+
return
|
|
82
|
+
} catch {
|
|
83
|
+
Log.warn("ElevenLabs TTS failed (\(error.localizedDescription)); falling back to say")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
_ = try? run("/usr/bin/say", [text])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private static func elevenLabsTTS(text: String, key: String) async throws -> Data {
|
|
90
|
+
let url = URL(string: "https://api.elevenlabs.io/v1/text-to-speech/\(Config.voiceID)")!
|
|
91
|
+
var req = URLRequest(url: url)
|
|
92
|
+
req.httpMethod = "POST"
|
|
93
|
+
req.timeoutInterval = 60
|
|
94
|
+
req.setValue(key, forHTTPHeaderField: "xi-api-key")
|
|
95
|
+
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
96
|
+
req.httpBody = try JSONSerialization.data(withJSONObject: [
|
|
97
|
+
"text": text,
|
|
98
|
+
"model_id": Config.ttsModel,
|
|
99
|
+
"voice_settings": ["stability": 0.5, "similarity_boost": 0.75],
|
|
100
|
+
])
|
|
101
|
+
let (data, response) = try await URLSession.shared.data(for: req)
|
|
102
|
+
try check(response, data, "text-to-speech")
|
|
103
|
+
return data
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: Helpers
|
|
107
|
+
|
|
108
|
+
private static func check(_ response: URLResponse, _ data: Data, _ what: String) throws {
|
|
109
|
+
guard let http = response as? HTTPURLResponse else { return }
|
|
110
|
+
guard (200..<300).contains(http.statusCode) else {
|
|
111
|
+
let detail = String(data: data.prefix(300), encoding: .utf8) ?? ""
|
|
112
|
+
throw VoiceError.api("\(what) HTTP \(http.statusCode): \(detail)")
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@discardableResult
|
|
117
|
+
static func run(_ path: String, _ args: [String]) throws -> String {
|
|
118
|
+
let p = Process()
|
|
119
|
+
p.executableURL = URL(fileURLWithPath: path)
|
|
120
|
+
p.arguments = args
|
|
121
|
+
let pipe = Pipe()
|
|
122
|
+
p.standardOutput = pipe
|
|
123
|
+
p.standardError = Pipe()
|
|
124
|
+
try p.run()
|
|
125
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
126
|
+
p.waitUntilExit()
|
|
127
|
+
return String(data: data, encoding: .utf8) ?? ""
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
enum VoiceError: LocalizedError {
|
|
132
|
+
case api(String)
|
|
133
|
+
var errorDescription: String? { if case .api(let m) = self { return m }; return nil }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Holds a strong reference to the player for the life of playback —
|
|
137
|
+
/// an AVAudioPlayer that goes out of scope stops mid-sentence.
|
|
138
|
+
final class Player: NSObject, AVAudioPlayerDelegate {
|
|
139
|
+
static let shared = Player()
|
|
140
|
+
private var player: AVAudioPlayer?
|
|
141
|
+
private var finished: CheckedContinuation<Void, Never>?
|
|
142
|
+
|
|
143
|
+
func play(_ mp3: Data) async throws {
|
|
144
|
+
let p = try AVAudioPlayer(data: mp3)
|
|
145
|
+
p.delegate = self
|
|
146
|
+
player = p
|
|
147
|
+
p.play()
|
|
148
|
+
await withCheckedContinuation { (c: CheckedContinuation<Void, Never>) in
|
|
149
|
+
finished = c
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
154
|
+
finished?.resume(); finished = nil; self.player = nil
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Logs to stderr AND to a file.
|
|
159
|
+
///
|
|
160
|
+
/// The app is normally launched with `open`, which sends stderr nowhere.
|
|
161
|
+
/// The first crash left a .ips report and no application log at all, so
|
|
162
|
+
/// the cause had to be reconstructed from a stack trace. A few lines of
|
|
163
|
+
/// file logging is the difference between reading what happened and
|
|
164
|
+
/// inferring it.
|
|
165
|
+
enum Log {
|
|
166
|
+
private static let queue = DispatchQueue(label: "tn.acme.agentx.voice.log")
|
|
167
|
+
private static let path = "\(NSHomeDirectory())/Library/Logs/agentx-voice.log"
|
|
168
|
+
|
|
169
|
+
static func warn(_ s: String) { write("WARN \(s)") }
|
|
170
|
+
static func info(_ s: String) { write("INFO \(s)") }
|
|
171
|
+
|
|
172
|
+
private static func write(_ s: String) {
|
|
173
|
+
let line = "[\(stamp())] \(s)\n"
|
|
174
|
+
FileHandle.standardError.write(line.data(using: .utf8)!)
|
|
175
|
+
queue.async {
|
|
176
|
+
guard let data = line.data(using: .utf8) else { return }
|
|
177
|
+
if let fh = FileHandle(forWritingAtPath: path) {
|
|
178
|
+
defer { try? fh.close() }
|
|
179
|
+
_ = try? fh.seekToEnd()
|
|
180
|
+
try? fh.write(contentsOf: data)
|
|
181
|
+
} else {
|
|
182
|
+
try? data.write(to: URL(fileURLWithPath: path))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static func stamp() -> String {
|
|
188
|
+
let f = DateFormatter()
|
|
189
|
+
f.dateFormat = "HH:mm:ss"
|
|
190
|
+
return f.string(from: Date())
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Turning a tool name into something a person would say.
|
|
4
|
+
///
|
|
5
|
+
/// The marquee was rendering the raw identifier off the event bus, so a
|
|
6
|
+
/// panel meant to tell you what the assistant is doing said:
|
|
7
|
+
///
|
|
8
|
+
/// mcp__agentx__agentx_task · 68s
|
|
9
|
+
///
|
|
10
|
+
/// Nobody outside this codebase can read that, and it is on screen during
|
|
11
|
+
/// the exact moments the widget exists for — the long waits when you want
|
|
12
|
+
/// to know whether anything is happening.
|
|
13
|
+
///
|
|
14
|
+
/// Deterministic on purpose. There is a decision seat that chooses what to
|
|
15
|
+
/// SAY out loud (seats/voice-narration.ts) and it earns its call, because
|
|
16
|
+
/// picking which steps are worth interrupting for is a judgement. This is
|
|
17
|
+
/// not that. Steps arrive several times a second, the mapping from tool to
|
|
18
|
+
/// verb is fixed, and a model in this path would add latency and cost to a
|
|
19
|
+
/// lookup table.
|
|
20
|
+
///
|
|
21
|
+
/// Two rules, from watching real steps go past:
|
|
22
|
+
/// - lead with the VERB, because the label is read at a glance and the
|
|
23
|
+
/// first word is the only one guaranteed to be seen before it scrolls
|
|
24
|
+
/// - carry a detail when there is a useful one, but show the file's name
|
|
25
|
+
/// rather than its full path — the last component is the part that
|
|
26
|
+
/// identifies it, and the leading directories eat the whole label
|
|
27
|
+
enum StepLabel {
|
|
28
|
+
|
|
29
|
+
/// Plain-language verb per tool. Present tense, no jargon, no tool names.
|
|
30
|
+
private static let verbs: [String: String] = [
|
|
31
|
+
"Bash": "Running a command",
|
|
32
|
+
"Read": "Reading",
|
|
33
|
+
"Edit": "Editing",
|
|
34
|
+
"Write": "Writing",
|
|
35
|
+
"NotebookEdit": "Editing a notebook",
|
|
36
|
+
"Glob": "Looking for files",
|
|
37
|
+
"Grep": "Searching the code",
|
|
38
|
+
"WebSearch": "Searching the web",
|
|
39
|
+
"WebFetch": "Reading a web page",
|
|
40
|
+
"TodoWrite": "Updating the plan",
|
|
41
|
+
"Task": "Handing work to a helper",
|
|
42
|
+
"Agent": "Handing work to a helper",
|
|
43
|
+
"Skill": "Using a skill",
|
|
44
|
+
"ToolSearch": "Finding the right tool",
|
|
45
|
+
"SendMessage": "Messaging another agent",
|
|
46
|
+
"ListAgents": "Checking who is available",
|
|
47
|
+
"ScheduleWakeup": "Scheduling a follow-up",
|
|
48
|
+
"Monitor": "Watching for something to finish",
|
|
49
|
+
"TaskOutput": "Checking on background work",
|
|
50
|
+
"TaskList": "Checking on background work",
|
|
51
|
+
"KillShell": "Stopping a command",
|
|
52
|
+
"BashOutput": "Checking a command's output",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/// agentx's own MCP tools, keyed by the bare name after the prefixes.
|
|
56
|
+
private static let agentxVerbs: [String: String] = [
|
|
57
|
+
"task": "Handing work to another agent",
|
|
58
|
+
"send": "Sending a message",
|
|
59
|
+
"send_agent": "Messaging another agent",
|
|
60
|
+
"send_contact": "Sending a message",
|
|
61
|
+
"channel_reply": "Replying on the channel",
|
|
62
|
+
"channel_label": "Labelling the conversation",
|
|
63
|
+
"recent": "Reading recent messages",
|
|
64
|
+
"agents": "Checking who is available",
|
|
65
|
+
"health": "Checking the system",
|
|
66
|
+
"crons": "Checking scheduled jobs",
|
|
67
|
+
"debug": "Checking the logs",
|
|
68
|
+
"inspect": "Inspecting the setup",
|
|
69
|
+
"generate": "Generating something",
|
|
70
|
+
"skill_match": "Finding a matching skill",
|
|
71
|
+
"wiki_query": "Searching the wiki",
|
|
72
|
+
"wiki_patch": "Updating the wiki",
|
|
73
|
+
"wiki_interview": "Working through the wiki",
|
|
74
|
+
"graph_review": "Reviewing the activity graph",
|
|
75
|
+
"attach_next": "Picking up an attachment",
|
|
76
|
+
"attach_answer": "Answering an attachment",
|
|
77
|
+
"detect_output_type": "Working out the reply format",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/// A person-facing phrase for a tool call, or nil when the step is not
|
|
81
|
+
/// worth putting on screen.
|
|
82
|
+
static func describe(tool: String, detail: String?) -> String? {
|
|
83
|
+
guard !tool.isEmpty, tool != "tool_use" else { return nil }
|
|
84
|
+
|
|
85
|
+
let verb = verbFor(tool)
|
|
86
|
+
guard let detail, !detail.isEmpty else { return verb }
|
|
87
|
+
|
|
88
|
+
// A skill reads better named than appended: "Using the gitlab
|
|
89
|
+
// skill", not "Using a skill: gitlab".
|
|
90
|
+
if tool == "Skill" {
|
|
91
|
+
let name = shorten(detail, for: tool)
|
|
92
|
+
if !name.isEmpty { return "Using the \(name) skill" }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A detail only helps when it is short enough to read while it
|
|
96
|
+
// scrolls past. Long ones are the whole point of the marquee, so
|
|
97
|
+
// they stay — but they are cleaned up first.
|
|
98
|
+
let shown = shorten(detail, for: tool)
|
|
99
|
+
return shown.isEmpty ? verb : "\(verb): \(shown)"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static func verbFor(_ tool: String) -> String {
|
|
103
|
+
if let known = verbs[tool] { return known }
|
|
104
|
+
|
|
105
|
+
// mcp__<server>__<name>. The server prefix is an implementation
|
|
106
|
+
// detail; the name is the only part with meaning in it.
|
|
107
|
+
if tool.hasPrefix("mcp__") {
|
|
108
|
+
let parts = tool.dropFirst(5).components(separatedBy: "__")
|
|
109
|
+
let server = parts.first ?? ""
|
|
110
|
+
var name = parts.count > 1 ? parts[1...].joined(separator: " ") : (parts.first ?? tool)
|
|
111
|
+
// agentx's tools are all named agentx_<verb>; the repetition
|
|
112
|
+
// carries nothing.
|
|
113
|
+
if name.hasPrefix("agentx_") { name = String(name.dropFirst("agentx_".count)) }
|
|
114
|
+
if server == "agentx", let known = agentxVerbs[name] { return known }
|
|
115
|
+
// For an unmapped MCP tool the NAME is the action and the
|
|
116
|
+
// server is packaging. "Search files" tells you what is
|
|
117
|
+
// happening; "Using Claude ai google drive" does not.
|
|
118
|
+
return humanWords(name.isEmpty ? server : name)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Something we have no phrase for. Spacing out the camel case is a
|
|
122
|
+
// better guess than the identifier, and is self-correcting: a tool
|
|
123
|
+
// nobody mapped reads as "Running Some Tool" rather than as code.
|
|
124
|
+
return humanWords(tool)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// `file_path` style values are long and front-loaded with directories
|
|
128
|
+
/// nobody is reading. The basename identifies the file.
|
|
129
|
+
private static func shorten(_ detail: String, for tool: String) -> String {
|
|
130
|
+
var text = detail
|
|
131
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
132
|
+
.trimmingCharacters(in: .whitespaces)
|
|
133
|
+
|
|
134
|
+
if ["Read", "Edit", "Write", "NotebookEdit"].contains(tool), text.contains("/") {
|
|
135
|
+
text = (text as NSString).lastPathComponent
|
|
136
|
+
}
|
|
137
|
+
// Collapse runs of whitespace left by flattened multi-line commands.
|
|
138
|
+
while text.contains(" ") { text = text.replacingOccurrences(of: " ", with: " ") }
|
|
139
|
+
return text
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// "agentx_send_agent" / "SomeToolName" -> "Some Tool Name".
|
|
143
|
+
private static func humanWords(_ raw: String) -> String {
|
|
144
|
+
let spaced = raw
|
|
145
|
+
.replacingOccurrences(of: "_", with: " ")
|
|
146
|
+
.replacingOccurrences(of: "-", with: " ")
|
|
147
|
+
var out = ""
|
|
148
|
+
var previousWasLower = false
|
|
149
|
+
for ch in spaced {
|
|
150
|
+
if ch.isUppercase && previousWasLower { out.append(" ") }
|
|
151
|
+
out.append(ch)
|
|
152
|
+
previousWasLower = ch.isLowercase || ch.isNumber
|
|
153
|
+
}
|
|
154
|
+
let words = out.split(separator: " ").map(String.init)
|
|
155
|
+
guard let first = words.first else { return raw }
|
|
156
|
+
return ([first.prefix(1).uppercased() + first.dropFirst()]
|
|
157
|
+
+ words.dropFirst().map { $0.lowercased() }).joined(separator: " ")
|
|
158
|
+
}
|
|
159
|
+
}
|