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.
Files changed (300) hide show
  1. package/README.md +67 -118
  2. package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
  3. package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
  4. package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
  5. package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
  6. package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
  7. package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
  8. package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
  9. package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
  10. package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
  11. package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
  12. package/apps/mac-helper/build.sh +39 -0
  13. package/apps/mac-voice/Resources/Info.plist +18 -0
  14. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
  16. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
  18. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
  19. package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
  23. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
  24. package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
  25. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
  26. package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
  27. package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
  28. package/apps/mac-voice/build.sh +32 -0
  29. package/dist/{App-BPNHIGUX.js → App-CKPH3HHF.js} +2 -2
  30. package/dist/{ChatApp-6XSSZB4U.js → ChatApp-MX7IG4K2.js} +2 -2
  31. package/dist/agent-2U574KBZ.js +2 -0
  32. package/dist/article-fields-L4D2BOGY.js +2 -0
  33. package/dist/article-quality-44ONVGLN.js +3 -0
  34. package/dist/article-quality-44ONVGLN.js.map +1 -0
  35. package/dist/{audit-JU3HWISB.js → audit-4245CX6P.js} +2 -2
  36. package/dist/backfill-MTD2B62I.js +6 -0
  37. package/dist/backfill-MTD2B62I.js.map +1 -0
  38. package/dist/{board-dashboard-M3372FLO.js → board-dashboard-QR7CVTKA.js} +1411 -453
  39. package/dist/board-dashboard-QR7CVTKA.js.map +1 -0
  40. package/dist/{builtin-FRKAQMFS.js → builtin-B67DMJ3M.js} +2 -2
  41. package/dist/calibration-ILS5OI44.js +2 -0
  42. package/dist/{chunk-V3U4OF7L.js → chunk-3LYM22IV.js} +46 -48
  43. package/dist/chunk-3LYM22IV.js.map +1 -0
  44. package/dist/chunk-3SCPRYJB.js +5 -0
  45. package/dist/chunk-3SCPRYJB.js.map +1 -0
  46. package/dist/chunk-4BR7D2OU.js +36 -0
  47. package/dist/chunk-4BR7D2OU.js.map +1 -0
  48. package/dist/{chunk-P4VWIVKN.js → chunk-52ZPELD2.js} +403 -29
  49. package/dist/chunk-52ZPELD2.js.map +1 -0
  50. package/dist/chunk-5IA2CYHN.js +5 -0
  51. package/dist/{chunk-O6YPEOQF.js.map → chunk-5IA2CYHN.js.map} +1 -1
  52. package/dist/chunk-64CAIB3C.js +9 -0
  53. package/dist/chunk-64CAIB3C.js.map +1 -0
  54. package/dist/{chunk-5U35WAZF.js → chunk-77OV7346.js} +6 -6
  55. package/dist/chunk-77OV7346.js.map +1 -0
  56. package/dist/chunk-AJ5RMU7X.js +2 -0
  57. package/dist/chunk-AJ5RMU7X.js.map +1 -0
  58. package/dist/chunk-AJONZWLT.js +2 -0
  59. package/dist/chunk-AJONZWLT.js.map +1 -0
  60. package/dist/chunk-BH3GTCZ6.js +2 -0
  61. package/dist/chunk-BH3GTCZ6.js.map +1 -0
  62. package/dist/chunk-E3UJHQGN.js +1189 -0
  63. package/dist/chunk-E3UJHQGN.js.map +1 -0
  64. package/dist/chunk-EVWEG3VD.js +3 -0
  65. package/dist/chunk-EVWEG3VD.js.map +1 -0
  66. package/dist/chunk-F4HZTZHW.js +2 -0
  67. package/dist/chunk-F4HZTZHW.js.map +1 -0
  68. package/dist/chunk-F6CNF7V3.js +25 -0
  69. package/dist/chunk-F6CNF7V3.js.map +1 -0
  70. package/dist/chunk-FIOB3MEX.js +2 -0
  71. package/dist/{chunk-H5D7ATXS.js → chunk-FKBGJYWQ.js} +5 -5
  72. package/dist/chunk-FKBGJYWQ.js.map +1 -0
  73. package/dist/{chunk-BVGJSPW4.js → chunk-G2HMRDUQ.js} +7 -7
  74. package/dist/chunk-G2HMRDUQ.js.map +1 -0
  75. package/dist/chunk-H2QQU2XV.js +109 -0
  76. package/dist/chunk-H2QQU2XV.js.map +1 -0
  77. package/dist/{chunk-LDHPMJ7M.js → chunk-IWMW5432.js} +3 -3
  78. package/dist/chunk-IWMW5432.js.map +1 -0
  79. package/dist/chunk-K33GIBSE.js +2 -0
  80. package/dist/chunk-K33GIBSE.js.map +1 -0
  81. package/dist/chunk-L2WSPY6S.js +2 -0
  82. package/dist/chunk-L2WSPY6S.js.map +1 -0
  83. package/dist/chunk-LCIREYTD.js +54 -0
  84. package/dist/chunk-LCIREYTD.js.map +1 -0
  85. package/dist/chunk-MHIMGM4G.js +126 -0
  86. package/dist/chunk-MHIMGM4G.js.map +1 -0
  87. package/dist/chunk-P5ZBBEPL.js +13 -0
  88. package/dist/chunk-P5ZBBEPL.js.map +1 -0
  89. package/dist/{chunk-JTJIPNXY.js → chunk-PNWNVOEZ.js} +1 -1
  90. package/dist/chunk-PNWNVOEZ.js.map +1 -0
  91. package/dist/chunk-QHTQPBHU.js +45 -0
  92. package/dist/chunk-QHTQPBHU.js.map +1 -0
  93. package/dist/chunk-S2MNL5V6.js +2 -0
  94. package/dist/chunk-S2MNL5V6.js.map +1 -0
  95. package/dist/chunk-SCTIUFL4.js +5 -0
  96. package/dist/{chunk-K2CAPYES.js.map → chunk-SCTIUFL4.js.map} +1 -1
  97. package/dist/chunk-ULLAE6LE.js +157 -0
  98. package/dist/chunk-ULLAE6LE.js.map +1 -0
  99. package/dist/{chunk-DJEZ2HVU.js → chunk-VDMYBPYB.js} +1 -1
  100. package/dist/chunk-VDMYBPYB.js.map +1 -0
  101. package/dist/chunk-VIH7ARPF.js +547 -0
  102. package/dist/chunk-VIH7ARPF.js.map +1 -0
  103. package/dist/chunk-W5C2GDNC.js +32 -0
  104. package/dist/{chunk-JP5IEXR7.js.map → chunk-W5C2GDNC.js.map} +1 -1
  105. package/dist/chunk-WPMX3VGI.js +3 -0
  106. package/dist/chunk-WPMX3VGI.js.map +1 -0
  107. package/dist/chunk-YLBERT7N.js +24 -0
  108. package/dist/chunk-YLBERT7N.js.map +1 -0
  109. package/dist/chunk-YLGIZRHV.js +114 -0
  110. package/dist/chunk-YLGIZRHV.js.map +1 -0
  111. package/dist/chunk-ZMYGXHPT.js +121 -0
  112. package/dist/chunk-ZMYGXHPT.js.map +1 -0
  113. package/dist/chunk-ZTEU5JVQ.js +2 -0
  114. package/dist/chunk-ZTEU5JVQ.js.map +1 -0
  115. package/dist/{chunk-2WVX3LEC.js → chunk-ZVP6GB4G.js} +1 -1
  116. package/dist/chunk-ZVP6GB4G.js.map +1 -0
  117. package/dist/cli.js +1 -1
  118. package/dist/cli.js.map +1 -1
  119. package/dist/client-ZLSXGPTV.js +2 -0
  120. package/dist/{compaction-NWEMOSHA.js → compaction-FD4R23HZ.js} +4 -4
  121. package/dist/config-HMW3EBZL.js +2 -0
  122. package/dist/config-mutator-4SZ5YEVD.js +2 -0
  123. package/dist/{conflict-detector-D7VIYPDI.js → conflict-detector-ZPOT3M2C.js} +2 -2
  124. package/dist/conflict-detector-ZPOT3M2C.js.map +1 -0
  125. package/dist/context-planner-AQHJMVY2.js +17 -0
  126. package/dist/context-planner-AQHJMVY2.js.map +1 -0
  127. package/dist/debug-3MEUO2P2.js +2 -0
  128. package/dist/{discover-XJXQXXIO.js → discover-D4VVRNAI.js} +2 -2
  129. package/dist/discover-D4VVRNAI.js.map +1 -0
  130. package/dist/doctor-LRYV3QLK.js +2 -0
  131. package/dist/editor-chat-OB7IQOB5.js +137 -0
  132. package/dist/editor-chat-OB7IQOB5.js.map +1 -0
  133. package/dist/entry-triage-LK34TSQD.js +2 -0
  134. package/dist/entry-triage-LK34TSQD.js.map +1 -0
  135. package/dist/facts-YVEJOM7O.js +22 -0
  136. package/dist/facts-YVEJOM7O.js.map +1 -0
  137. package/dist/heal-FVLBHYP7.js +2 -0
  138. package/dist/index.d.ts +1040 -64
  139. package/dist/index.js +12 -19
  140. package/dist/index.js.map +1 -1
  141. package/dist/{ingest-whatsapp-LZE62INA.js → ingest-whatsapp-NS4OWDOU.js} +2 -2
  142. package/dist/loader-4ZGTK7LD.js +2 -0
  143. package/dist/loader-BOKE5M5S.js +2 -0
  144. package/dist/memory-extract-F5ZHWY5E.js +2 -0
  145. package/dist/{migrate-v2-QO7SLKIM.js → migrate-v2-V4WN2VVM.js} +2 -2
  146. package/dist/ntfy-X7EPB3TG.js +4 -0
  147. package/dist/ntfy-X7EPB3TG.js.map +1 -0
  148. package/dist/processes-WU23U2OJ.js +2 -0
  149. package/dist/program-GSZ5AYAM.js +2 -0
  150. package/dist/{projects-api-KRME2YKZ.js → projects-api-QE6L65SK.js} +2 -2
  151. package/dist/{projects-mutate-3NHVG437.js → projects-mutate-MY7JTLSZ.js} +2 -2
  152. package/dist/providers-KO2DODIO.js +2 -0
  153. package/dist/query-SQDHE4HV.js +2 -0
  154. package/dist/questions-4B4KSPXG.js +3 -0
  155. package/dist/questions-4B4KSPXG.js.map +1 -0
  156. package/dist/recipes-7S4MC5OJ.js +2 -0
  157. package/dist/registry-LE6FWYZC.js +2 -0
  158. package/dist/review-candidates-RQU5KNZM.js +4 -0
  159. package/dist/review-candidates-RQU5KNZM.js.map +1 -0
  160. package/dist/review-store-B4YE3EFK.js +4 -0
  161. package/dist/review-store-B4YE3EFK.js.map +1 -0
  162. package/dist/{rotation-memo-B3EXXSEH.js → rotation-memo-7T75BLEX.js} +2 -2
  163. package/dist/routing-ILERJXB6.js +2 -0
  164. package/dist/runner-YRBVNYTY.js +2 -0
  165. package/dist/scheduler-BFM3EINS.js +2 -0
  166. package/dist/seat-7WM7II2B.js +2 -0
  167. package/dist/select-X4KVMOP5.js +2 -0
  168. package/dist/select-X4KVMOP5.js.map +1 -0
  169. package/dist/store-MCOA6N4F.js +2 -0
  170. package/dist/{subagent-WV7QKQ3L.js → subagent-2SJHSSJS.js} +2 -2
  171. package/dist/{surface-usage-PRL6Z4U3.js → surface-usage-NS74AXSE.js} +2 -2
  172. package/dist/timers-PCB4ZIBD.js +2 -0
  173. package/dist/triage-backtest-JNO7RKX7.js +2 -0
  174. package/dist/triage-backtest-JNO7RKX7.js.map +1 -0
  175. package/dist/types-H5ENIUIT.js +2 -0
  176. package/dist/types-OIFU3KSQ.js +2 -0
  177. package/dist/types-OIFU3KSQ.js.map +1 -0
  178. package/dist/web/workflow-editor.global.js +1542 -0
  179. package/dist/web/workflow-editor.global.js.map +1 -0
  180. package/dist/{whatsapp-state-XMTCSRXU.js → whatsapp-state-6D6FHHD2.js} +2 -2
  181. package/dist/wiki-2AUFSIKO.js +2 -0
  182. package/dist/wiki-2AUFSIKO.js.map +1 -0
  183. package/dist/workflows/templates/branching.yaml +1 -1
  184. package/dist/{workspace-setup-TBQAHYHJ.js → workspace-setup-B3F3TUDE.js} +2 -2
  185. package/dist/workspace-setup-B3F3TUDE.js.map +1 -0
  186. package/package.json +14 -3
  187. package/scripts/postinstall.mjs +80 -0
  188. package/dist/agent-5QMITZT6.js +0 -2
  189. package/dist/architect-SEPWMWS4.js +0 -2
  190. package/dist/board-dashboard-M3372FLO.js.map +0 -1
  191. package/dist/chunk-2WVX3LEC.js.map +0 -1
  192. package/dist/chunk-3WQFCIB6.js +0 -24
  193. package/dist/chunk-3WQFCIB6.js.map +0 -1
  194. package/dist/chunk-4YCH6IZV.js +0 -2
  195. package/dist/chunk-53NRX2NA.js +0 -2
  196. package/dist/chunk-53NRX2NA.js.map +0 -1
  197. package/dist/chunk-5U35WAZF.js.map +0 -1
  198. package/dist/chunk-BFXONUUN.js +0 -108
  199. package/dist/chunk-BFXONUUN.js.map +0 -1
  200. package/dist/chunk-BFZUG66X.js +0 -45
  201. package/dist/chunk-BFZUG66X.js.map +0 -1
  202. package/dist/chunk-BTHHSY7D.js +0 -13
  203. package/dist/chunk-BTHHSY7D.js.map +0 -1
  204. package/dist/chunk-BVGJSPW4.js.map +0 -1
  205. package/dist/chunk-D46VZ7K7.js +0 -116
  206. package/dist/chunk-D46VZ7K7.js.map +0 -1
  207. package/dist/chunk-DJEZ2HVU.js.map +0 -1
  208. package/dist/chunk-E5CXCI33.js +0 -155
  209. package/dist/chunk-E5CXCI33.js.map +0 -1
  210. package/dist/chunk-FDWOECJY.js +0 -54
  211. package/dist/chunk-FDWOECJY.js.map +0 -1
  212. package/dist/chunk-H5D7ATXS.js.map +0 -1
  213. package/dist/chunk-HR4JSRZS.js +0 -3
  214. package/dist/chunk-HR4JSRZS.js.map +0 -1
  215. package/dist/chunk-ISEBLHWX.js +0 -118
  216. package/dist/chunk-ISEBLHWX.js.map +0 -1
  217. package/dist/chunk-JP5IEXR7.js +0 -32
  218. package/dist/chunk-JTJIPNXY.js.map +0 -1
  219. package/dist/chunk-K2CAPYES.js +0 -5
  220. package/dist/chunk-LDHPMJ7M.js.map +0 -1
  221. package/dist/chunk-METYYZKQ.js +0 -1115
  222. package/dist/chunk-METYYZKQ.js.map +0 -1
  223. package/dist/chunk-MTAPOCGA.js +0 -47
  224. package/dist/chunk-MTAPOCGA.js.map +0 -1
  225. package/dist/chunk-O6YPEOQF.js +0 -5
  226. package/dist/chunk-P4VWIVKN.js.map +0 -1
  227. package/dist/chunk-SR3X25TN.js +0 -3
  228. package/dist/chunk-SR3X25TN.js.map +0 -1
  229. package/dist/chunk-UHE5FCPB.js +0 -431
  230. package/dist/chunk-UHE5FCPB.js.map +0 -1
  231. package/dist/chunk-V3U4OF7L.js.map +0 -1
  232. package/dist/chunk-VJUTQAPJ.js +0 -2
  233. package/dist/chunk-VJUTQAPJ.js.map +0 -1
  234. package/dist/chunk-VT5XYFDD.js +0 -126
  235. package/dist/chunk-VT5XYFDD.js.map +0 -1
  236. package/dist/client-HGKAC6VP.js +0 -2
  237. package/dist/config-A4RWEJHV.js +0 -2
  238. package/dist/config-mutator-MNSFUN7D.js +0 -2
  239. package/dist/conflict-detector-D7VIYPDI.js.map +0 -1
  240. package/dist/context-planner-45GEWKGL.js +0 -17
  241. package/dist/context-planner-45GEWKGL.js.map +0 -1
  242. package/dist/debug-N3B5NVJU.js +0 -2
  243. package/dist/discover-XJXQXXIO.js.map +0 -1
  244. package/dist/doctor-3Q36ZFE6.js +0 -2
  245. package/dist/heal-HZ3EQD52.js +0 -2
  246. package/dist/loader-5CA3O5GM.js +0 -2
  247. package/dist/loader-6IHUCPVO.js +0 -2
  248. package/dist/memory-extract-AENFVVO5.js +0 -2
  249. package/dist/processes-UIC4XXA6.js +0 -2
  250. package/dist/program-SAUNRCCD.js +0 -2
  251. package/dist/providers-L27BJ5A6.js +0 -2
  252. package/dist/query-HZYYRCLU.js +0 -2
  253. package/dist/recipes-EPLE2SNT.js +0 -2
  254. package/dist/registry-HK3WQ7BS.js +0 -2
  255. package/dist/runner-BFYDWPUO.js +0 -2
  256. package/dist/scheduler-6T2SU5ST.js +0 -2
  257. package/dist/store-I3HEWVLQ.js +0 -2
  258. package/dist/timers-5LPZN55J.js +0 -2
  259. package/dist/types-VECTMYTO.js +0 -2
  260. package/dist/types-Y6ZCZ52Z.js +0 -2
  261. package/dist/wiki-M6YNRFC4.js +0 -2
  262. /package/dist/{App-BPNHIGUX.js.map → App-CKPH3HHF.js.map} +0 -0
  263. /package/dist/{ChatApp-6XSSZB4U.js.map → ChatApp-MX7IG4K2.js.map} +0 -0
  264. /package/dist/{agent-5QMITZT6.js.map → agent-2U574KBZ.js.map} +0 -0
  265. /package/dist/{architect-SEPWMWS4.js.map → article-fields-L4D2BOGY.js.map} +0 -0
  266. /package/dist/{audit-JU3HWISB.js.map → audit-4245CX6P.js.map} +0 -0
  267. /package/dist/{builtin-FRKAQMFS.js.map → builtin-B67DMJ3M.js.map} +0 -0
  268. /package/dist/{chunk-4YCH6IZV.js.map → calibration-ILS5OI44.js.map} +0 -0
  269. /package/dist/{client-HGKAC6VP.js.map → chunk-FIOB3MEX.js.map} +0 -0
  270. /package/dist/{config-A4RWEJHV.js.map → client-ZLSXGPTV.js.map} +0 -0
  271. /package/dist/{compaction-NWEMOSHA.js.map → compaction-FD4R23HZ.js.map} +0 -0
  272. /package/dist/{config-mutator-MNSFUN7D.js.map → config-HMW3EBZL.js.map} +0 -0
  273. /package/dist/{debug-N3B5NVJU.js.map → config-mutator-4SZ5YEVD.js.map} +0 -0
  274. /package/dist/{doctor-3Q36ZFE6.js.map → debug-3MEUO2P2.js.map} +0 -0
  275. /package/dist/{heal-HZ3EQD52.js.map → doctor-LRYV3QLK.js.map} +0 -0
  276. /package/dist/{loader-5CA3O5GM.js.map → heal-FVLBHYP7.js.map} +0 -0
  277. /package/dist/{ingest-whatsapp-LZE62INA.js.map → ingest-whatsapp-NS4OWDOU.js.map} +0 -0
  278. /package/dist/{loader-6IHUCPVO.js.map → loader-4ZGTK7LD.js.map} +0 -0
  279. /package/dist/{memory-extract-AENFVVO5.js.map → loader-BOKE5M5S.js.map} +0 -0
  280. /package/dist/{processes-UIC4XXA6.js.map → memory-extract-F5ZHWY5E.js.map} +0 -0
  281. /package/dist/{migrate-v2-QO7SLKIM.js.map → migrate-v2-V4WN2VVM.js.map} +0 -0
  282. /package/dist/{program-SAUNRCCD.js.map → processes-WU23U2OJ.js.map} +0 -0
  283. /package/dist/{providers-L27BJ5A6.js.map → program-GSZ5AYAM.js.map} +0 -0
  284. /package/dist/{projects-api-KRME2YKZ.js.map → projects-api-QE6L65SK.js.map} +0 -0
  285. /package/dist/{projects-mutate-3NHVG437.js.map → projects-mutate-MY7JTLSZ.js.map} +0 -0
  286. /package/dist/{query-HZYYRCLU.js.map → providers-KO2DODIO.js.map} +0 -0
  287. /package/dist/{recipes-EPLE2SNT.js.map → query-SQDHE4HV.js.map} +0 -0
  288. /package/dist/{registry-HK3WQ7BS.js.map → recipes-7S4MC5OJ.js.map} +0 -0
  289. /package/dist/{runner-BFYDWPUO.js.map → registry-LE6FWYZC.js.map} +0 -0
  290. /package/dist/{rotation-memo-B3EXXSEH.js.map → rotation-memo-7T75BLEX.js.map} +0 -0
  291. /package/dist/{scheduler-6T2SU5ST.js.map → routing-ILERJXB6.js.map} +0 -0
  292. /package/dist/{store-I3HEWVLQ.js.map → runner-YRBVNYTY.js.map} +0 -0
  293. /package/dist/{surface-usage-PRL6Z4U3.js.map → scheduler-BFM3EINS.js.map} +0 -0
  294. /package/dist/{timers-5LPZN55J.js.map → seat-7WM7II2B.js.map} +0 -0
  295. /package/dist/{types-VECTMYTO.js.map → store-MCOA6N4F.js.map} +0 -0
  296. /package/dist/{subagent-WV7QKQ3L.js.map → subagent-2SJHSSJS.js.map} +0 -0
  297. /package/dist/{types-Y6ZCZ52Z.js.map → surface-usage-NS74AXSE.js.map} +0 -0
  298. /package/dist/{wiki-M6YNRFC4.js.map → timers-PCB4ZIBD.js.map} +0 -0
  299. /package/dist/{workspace-setup-TBQAHYHJ.js.map → types-H5ENIUIT.js.map} +0 -0
  300. /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
+ }