holdspeak 0.2.1__py3-none-any.whl

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 (348) hide show
  1. holdspeak/__init__.py +40 -0
  2. holdspeak/activity_candidates.py +120 -0
  3. holdspeak/activity_connector_preview.py +242 -0
  4. holdspeak/activity_connectors.py +140 -0
  5. holdspeak/activity_context.py +154 -0
  6. holdspeak/activity_entities.py +139 -0
  7. holdspeak/activity_extension.py +253 -0
  8. holdspeak/activity_github.py +293 -0
  9. holdspeak/activity_history.py +394 -0
  10. holdspeak/activity_jira.py +265 -0
  11. holdspeak/activity_mapping.py +114 -0
  12. holdspeak/agent_context/__init__.py +152 -0
  13. holdspeak/agent_context/_common.py +26 -0
  14. holdspeak/agent_context/hooks.py +132 -0
  15. holdspeak/agent_context/hs_context.py +341 -0
  16. holdspeak/agent_context/models.py +184 -0
  17. holdspeak/agent_context/sessions.py +791 -0
  18. holdspeak/agent_device.py +174 -0
  19. holdspeak/agent_summarizer.py +345 -0
  20. holdspeak/artifacts.py +64 -0
  21. holdspeak/audio.py +232 -0
  22. holdspeak/audio_devices.py +383 -0
  23. holdspeak/commands/__init__.py +1 -0
  24. holdspeak/commands/actions.py +61 -0
  25. holdspeak/commands/agent_hook.py +172 -0
  26. holdspeak/commands/backup.py +77 -0
  27. holdspeak/commands/device.py +47 -0
  28. holdspeak/commands/dictation.py +297 -0
  29. holdspeak/commands/doctor.py +1104 -0
  30. holdspeak/commands/history.py +157 -0
  31. holdspeak/commands/intel.py +403 -0
  32. holdspeak/config.py +556 -0
  33. holdspeak/connector_fixtures.py +347 -0
  34. holdspeak/connector_pack_loader.py +423 -0
  35. holdspeak/connector_packs/__init__.py +42 -0
  36. holdspeak/connector_packs/calendar_activity.py +111 -0
  37. holdspeak/connector_packs/firefox_ext.py +66 -0
  38. holdspeak/connector_packs/github_cli.py +148 -0
  39. holdspeak/connector_packs/jira_cli.py +123 -0
  40. holdspeak/connector_packs/meeting_context.py +299 -0
  41. holdspeak/connector_runtime.py +429 -0
  42. holdspeak/connector_sdk.py +762 -0
  43. holdspeak/db/__init__.py +25 -0
  44. holdspeak/db/activity.py +1553 -0
  45. holdspeak/db/actuators.py +294 -0
  46. holdspeak/db/base.py +49 -0
  47. holdspeak/db/core.py +903 -0
  48. holdspeak/db/corrections.py +110 -0
  49. holdspeak/db/intel.py +394 -0
  50. holdspeak/db/journal.py +215 -0
  51. holdspeak/db/meetings.py +890 -0
  52. holdspeak/db/milestones.py +79 -0
  53. holdspeak/db/models.py +444 -0
  54. holdspeak/db/plugins.py +814 -0
  55. holdspeak/db/projects.py +463 -0
  56. holdspeak/desktop_presence.py +367 -0
  57. holdspeak/desktop_presence_cocoa.py +287 -0
  58. holdspeak/desktop_presence_freedesktop.py +267 -0
  59. holdspeak/desktop_presence_gtk.py +243 -0
  60. holdspeak/device_audio.py +602 -0
  61. holdspeak/device_audio_ws.py +572 -0
  62. holdspeak/device_meeting_stats.py +154 -0
  63. holdspeak/device_recording_tick.py +153 -0
  64. holdspeak/device_status.py +411 -0
  65. holdspeak/dictation_learning.py +272 -0
  66. holdspeak/dictation_telemetry.py +230 -0
  67. holdspeak/hotkey.py +164 -0
  68. holdspeak/intel/__init__.py +111 -0
  69. holdspeak/intel/engine.py +547 -0
  70. holdspeak/intel/models.py +88 -0
  71. holdspeak/intel/parsing.py +209 -0
  72. holdspeak/intel/providers.py +267 -0
  73. holdspeak/intel_queue.py +482 -0
  74. holdspeak/intent_timeline.py +112 -0
  75. holdspeak/logging_config.py +47 -0
  76. holdspeak/main.py +562 -0
  77. holdspeak/meeting.py +703 -0
  78. holdspeak/meeting_aftercare.py +355 -0
  79. holdspeak/meeting_exports.py +245 -0
  80. holdspeak/meeting_session.py +1659 -0
  81. holdspeak/plugin_pack_loader.py +420 -0
  82. holdspeak/plugin_packs/__init__.py +26 -0
  83. holdspeak/plugin_sdk.py +330 -0
  84. holdspeak/plugins/__init__.py +100 -0
  85. holdspeak/plugins/actuator_executor.py +170 -0
  86. holdspeak/plugins/actuators.py +140 -0
  87. holdspeak/plugins/builtin/__init__.py +195 -0
  88. holdspeak/plugins/builtin/action_owner_enforcer.py +237 -0
  89. holdspeak/plugins/builtin/adr_drafter.py +231 -0
  90. holdspeak/plugins/builtin/customer_signal_extractor.py +211 -0
  91. holdspeak/plugins/builtin/decision_announcement_drafter.py +174 -0
  92. holdspeak/plugins/builtin/decision_capture.py +200 -0
  93. holdspeak/plugins/builtin/dependency_mapper.py +173 -0
  94. holdspeak/plugins/builtin/followup_ticket_actuator.py +165 -0
  95. holdspeak/plugins/builtin/github_issue_actuator.py +245 -0
  96. holdspeak/plugins/builtin/incident_timeline.py +174 -0
  97. holdspeak/plugins/builtin/mermaid_architecture.py +288 -0
  98. holdspeak/plugins/builtin/milestone_planner.py +218 -0
  99. holdspeak/plugins/builtin/requirements_extractor.py +226 -0
  100. holdspeak/plugins/builtin/risk_heatmap.py +237 -0
  101. holdspeak/plugins/builtin/runbook_delta.py +198 -0
  102. holdspeak/plugins/builtin/scope_guard.py +207 -0
  103. holdspeak/plugins/builtin/stakeholder_update_drafter.py +175 -0
  104. holdspeak/plugins/builtin/webhook_post_actuator.py +205 -0
  105. holdspeak/plugins/contracts.py +153 -0
  106. holdspeak/plugins/dictation/__init__.py +6 -0
  107. holdspeak/plugins/dictation/assembly.py +142 -0
  108. holdspeak/plugins/dictation/blocks.py +353 -0
  109. holdspeak/plugins/dictation/builtin/__init__.py +7 -0
  110. holdspeak/plugins/dictation/builtin/intent_router.py +241 -0
  111. holdspeak/plugins/dictation/builtin/kb_enricher.py +187 -0
  112. holdspeak/plugins/dictation/builtin/project_rewriter.py +441 -0
  113. holdspeak/plugins/dictation/contracts.py +64 -0
  114. holdspeak/plugins/dictation/corrections.py +242 -0
  115. holdspeak/plugins/dictation/grammars.py +217 -0
  116. holdspeak/plugins/dictation/guidance.py +235 -0
  117. holdspeak/plugins/dictation/journal.py +137 -0
  118. holdspeak/plugins/dictation/pipeline.py +145 -0
  119. holdspeak/plugins/dictation/project_kb.py +122 -0
  120. holdspeak/plugins/dictation/project_root.py +166 -0
  121. holdspeak/plugins/dictation/runtime.py +228 -0
  122. holdspeak/plugins/dictation/runtime_counters.py +303 -0
  123. holdspeak/plugins/dictation/runtime_llama_cpp.py +184 -0
  124. holdspeak/plugins/dictation/runtime_mlx.py +191 -0
  125. holdspeak/plugins/dictation/runtime_openai_compatible.py +277 -0
  126. holdspeak/plugins/dictation/telemetry_store.py +102 -0
  127. holdspeak/plugins/dispatch.py +237 -0
  128. holdspeak/plugins/gated_connector.py +299 -0
  129. holdspeak/plugins/host.py +610 -0
  130. holdspeak/plugins/persistence.py +165 -0
  131. holdspeak/plugins/pipeline.py +225 -0
  132. holdspeak/plugins/project_detector.py +111 -0
  133. holdspeak/plugins/queue.py +179 -0
  134. holdspeak/plugins/router.py +206 -0
  135. holdspeak/plugins/scoring.py +99 -0
  136. holdspeak/plugins/segment_probe.py +165 -0
  137. holdspeak/plugins/signals.py +64 -0
  138. holdspeak/plugins/synthesis.py +749 -0
  139. holdspeak/project_doc_suggestions.py +315 -0
  140. holdspeak/runtime_activity.py +156 -0
  141. holdspeak/setup_runtime.py +111 -0
  142. holdspeak/setup_status.py +198 -0
  143. holdspeak/speaker_intel.py +403 -0
  144. holdspeak/static/_built/_astro/AppLayout.BQCpCpOb.css +1 -0
  145. holdspeak/static/_built/_astro/activity.DA_tfAUv.css +1 -0
  146. holdspeak/static/_built/_astro/activity.astro_astro_type_script_index_0_lang.DuqbHxsc.js +792 -0
  147. holdspeak/static/_built/_astro/arc.C9-mI4Co.js +1 -0
  148. holdspeak/static/_built/_astro/architectureDiagram-3BPJPVTR.DWncxEGY.js +36 -0
  149. holdspeak/static/_built/_astro/blockDiagram-GPEHLZMM.BcmuLtcp.js +132 -0
  150. holdspeak/static/_built/_astro/briefing-markdown.CG6qhLV7.js +72 -0
  151. holdspeak/static/_built/_astro/c4Diagram-AAUBKEIU.D0YM09iU.js +10 -0
  152. holdspeak/static/_built/_astro/channel.BHojkgFI.js +1 -0
  153. holdspeak/static/_built/_astro/chunk-2J33WTMH.CJ33DlQS.js +1 -0
  154. holdspeak/static/_built/_astro/chunk-4BX2VUAB.B7RJHp_O.js +1 -0
  155. holdspeak/static/_built/_astro/chunk-55IACEB6.BDSs_AEM.js +1 -0
  156. holdspeak/static/_built/_astro/chunk-727SXJPM.DPJ0Pqm_.js +206 -0
  157. holdspeak/static/_built/_astro/chunk-AQP2D5EJ.Css1Km4r.js +231 -0
  158. holdspeak/static/_built/_astro/chunk-FMBD7UC4.bzIS3Sbr.js +15 -0
  159. holdspeak/static/_built/_astro/chunk-ND2GUHAM.240nFkAJ.js +1 -0
  160. holdspeak/static/_built/_astro/chunk-QZHKN3VN.B4EOMkxi.js +1 -0
  161. holdspeak/static/_built/_astro/classDiagram-4FO5ZUOK.BJJlpiT3.js +1 -0
  162. holdspeak/static/_built/_astro/classDiagram-v2-Q7XG4LA2.BJJlpiT3.js +1 -0
  163. holdspeak/static/_built/_astro/companion.-9QC2i0c.css +1 -0
  164. holdspeak/static/_built/_astro/companion.astro_astro_type_script_index_0_lang.BjniKhot.js +193 -0
  165. holdspeak/static/_built/_astro/components.Ck31oQ9K.css +1 -0
  166. holdspeak/static/_built/_astro/cose-bilkent-S5V4N54A.DTSBLznh.js +1 -0
  167. holdspeak/static/_built/_astro/cytoscape.esm.CkSuTymj.js +321 -0
  168. holdspeak/static/_built/_astro/dagre-BM42HDAG.zt96dTxi.js +4 -0
  169. holdspeak/static/_built/_astro/defaultLocale.DX6XiGOO.js +1 -0
  170. holdspeak/static/_built/_astro/diagram-2AECGRRQ.D9XBzHxF.js +43 -0
  171. holdspeak/static/_built/_astro/diagram-5GNKFQAL.sNM7LTUu.js +10 -0
  172. holdspeak/static/_built/_astro/diagram-KO2AKTUF.Ca2PRDWQ.js +3 -0
  173. holdspeak/static/_built/_astro/diagram-LMA3HP47.DsT__rnS.js +24 -0
  174. holdspeak/static/_built/_astro/diagram-OG6HWLK6.TL8Yqa7l.js +24 -0
  175. holdspeak/static/_built/_astro/dictation.BvC1-5I-.css +1 -0
  176. holdspeak/static/_built/_astro/dictation.astro_astro_type_script_index_0_lang.DnK0bob4.js +2703 -0
  177. holdspeak/static/_built/_astro/erDiagram-TEJ5UH35.BX4Bu8K2.js +85 -0
  178. holdspeak/static/_built/_astro/flowDiagram-I6XJVG4X.DLX_YAKZ.js +162 -0
  179. holdspeak/static/_built/_astro/ganttDiagram-6RSMTGT7.CUgTcpLU.js +292 -0
  180. holdspeak/static/_built/_astro/gitGraphDiagram-PVQCEYII.D4CrYgXJ.js +106 -0
  181. holdspeak/static/_built/_astro/global.BJQBsoEX.css +1 -0
  182. holdspeak/static/_built/_astro/graph.-OzhPTMs.js +1 -0
  183. holdspeak/static/_built/_astro/history.CEex34w_.css +1 -0
  184. holdspeak/static/_built/_astro/history.astro_astro_type_script_index_0_lang.C2uhSDck.js +1442 -0
  185. holdspeak/static/_built/_astro/index.COQeraZI.css +1 -0
  186. holdspeak/static/_built/_astro/index.astro_astro_type_script_index_0_lang.Dl3tsaO-.js +1529 -0
  187. holdspeak/static/_built/_astro/infoDiagram-5YYISTIA.C7cseoqe.js +2 -0
  188. holdspeak/static/_built/_astro/init.Gi6I4Gst.js +1 -0
  189. holdspeak/static/_built/_astro/inter-cyrillic-400-normal.HOLc17fK.woff +0 -0
  190. holdspeak/static/_built/_astro/inter-cyrillic-400-normal.obahsSVq.woff2 +0 -0
  191. holdspeak/static/_built/_astro/inter-cyrillic-500-normal.BasfLYem.woff2 +0 -0
  192. holdspeak/static/_built/_astro/inter-cyrillic-500-normal.CxZf_p3X.woff +0 -0
  193. holdspeak/static/_built/_astro/inter-cyrillic-600-normal.4D_pXhcN.woff +0 -0
  194. holdspeak/static/_built/_astro/inter-cyrillic-600-normal.CWCymEST.woff2 +0 -0
  195. holdspeak/static/_built/_astro/inter-cyrillic-ext-400-normal.BQZuk6qB.woff2 +0 -0
  196. holdspeak/static/_built/_astro/inter-cyrillic-ext-400-normal.DQukG94-.woff +0 -0
  197. holdspeak/static/_built/_astro/inter-cyrillic-ext-500-normal.B0yAr1jD.woff2 +0 -0
  198. holdspeak/static/_built/_astro/inter-cyrillic-ext-500-normal.BmqWE9Dz.woff +0 -0
  199. holdspeak/static/_built/_astro/inter-cyrillic-ext-600-normal.Bcila6Z-.woff +0 -0
  200. holdspeak/static/_built/_astro/inter-cyrillic-ext-600-normal.Dfes3d0z.woff2 +0 -0
  201. holdspeak/static/_built/_astro/inter-greek-400-normal.B4URO6DV.woff2 +0 -0
  202. holdspeak/static/_built/_astro/inter-greek-400-normal.q2sYcFCs.woff +0 -0
  203. holdspeak/static/_built/_astro/inter-greek-500-normal.BIZE56-Y.woff2 +0 -0
  204. holdspeak/static/_built/_astro/inter-greek-500-normal.Xzm54t5V.woff +0 -0
  205. holdspeak/static/_built/_astro/inter-greek-600-normal.BZpKdvQh.woff +0 -0
  206. holdspeak/static/_built/_astro/inter-greek-600-normal.plRanbMR.woff2 +0 -0
  207. holdspeak/static/_built/_astro/inter-greek-ext-400-normal.DGGRlc-M.woff2 +0 -0
  208. holdspeak/static/_built/_astro/inter-greek-ext-400-normal.KugGGMne.woff +0 -0
  209. holdspeak/static/_built/_astro/inter-greek-ext-500-normal.2j5mBUwD.woff +0 -0
  210. holdspeak/static/_built/_astro/inter-greek-ext-500-normal.C4iEst2y.woff2 +0 -0
  211. holdspeak/static/_built/_astro/inter-greek-ext-600-normal.B8X0CLgF.woff +0 -0
  212. holdspeak/static/_built/_astro/inter-greek-ext-600-normal.DRtmH8MT.woff2 +0 -0
  213. holdspeak/static/_built/_astro/inter-latin-400-normal.C38fXH4l.woff2 +0 -0
  214. holdspeak/static/_built/_astro/inter-latin-400-normal.CyCys3Eg.woff +0 -0
  215. holdspeak/static/_built/_astro/inter-latin-500-normal.BL9OpVg8.woff +0 -0
  216. holdspeak/static/_built/_astro/inter-latin-500-normal.Cerq10X2.woff2 +0 -0
  217. holdspeak/static/_built/_astro/inter-latin-600-normal.CiBQ2DWP.woff +0 -0
  218. holdspeak/static/_built/_astro/inter-latin-600-normal.LgqL8muc.woff2 +0 -0
  219. holdspeak/static/_built/_astro/inter-latin-ext-400-normal.77YHD8bZ.woff +0 -0
  220. holdspeak/static/_built/_astro/inter-latin-ext-400-normal.C1nco2VV.woff2 +0 -0
  221. holdspeak/static/_built/_astro/inter-latin-ext-500-normal.BxGbmqWO.woff +0 -0
  222. holdspeak/static/_built/_astro/inter-latin-ext-500-normal.CV4jyFjo.woff2 +0 -0
  223. holdspeak/static/_built/_astro/inter-latin-ext-600-normal.CIVaiw4L.woff +0 -0
  224. holdspeak/static/_built/_astro/inter-latin-ext-600-normal.D2bJ5OIk.woff2 +0 -0
  225. holdspeak/static/_built/_astro/inter-vietnamese-400-normal.Bbgyi5SW.woff +0 -0
  226. holdspeak/static/_built/_astro/inter-vietnamese-400-normal.DMkecbls.woff2 +0 -0
  227. holdspeak/static/_built/_astro/inter-vietnamese-500-normal.DOriooB6.woff2 +0 -0
  228. holdspeak/static/_built/_astro/inter-vietnamese-500-normal.mJboJaSs.woff +0 -0
  229. holdspeak/static/_built/_astro/inter-vietnamese-600-normal.BuLX-rYi.woff +0 -0
  230. holdspeak/static/_built/_astro/inter-vietnamese-600-normal.Cc8MFFhd.woff2 +0 -0
  231. holdspeak/static/_built/_astro/ishikawaDiagram-YF4QCWOH.BdfUqAt-.js +70 -0
  232. holdspeak/static/_built/_astro/jetbrains-mono-cyrillic-400-normal.BEIGL1Tu.woff2 +0 -0
  233. holdspeak/static/_built/_astro/jetbrains-mono-cyrillic-400-normal.ugxPyKxw.woff +0 -0
  234. holdspeak/static/_built/_astro/jetbrains-mono-cyrillic-500-normal.DJqRU3vO.woff +0 -0
  235. holdspeak/static/_built/_astro/jetbrains-mono-cyrillic-500-normal.DmUKJPL_.woff2 +0 -0
  236. holdspeak/static/_built/_astro/jetbrains-mono-greek-400-normal.B9oWc5Lo.woff +0 -0
  237. holdspeak/static/_built/_astro/jetbrains-mono-greek-400-normal.C190GLew.woff2 +0 -0
  238. holdspeak/static/_built/_astro/jetbrains-mono-greek-500-normal.D7SFKleX.woff +0 -0
  239. holdspeak/static/_built/_astro/jetbrains-mono-greek-500-normal.JpySY46c.woff2 +0 -0
  240. holdspeak/static/_built/_astro/jetbrains-mono-latin-400-normal.6-qcROiO.woff +0 -0
  241. holdspeak/static/_built/_astro/jetbrains-mono-latin-400-normal.V6pRDFza.woff2 +0 -0
  242. holdspeak/static/_built/_astro/jetbrains-mono-latin-500-normal.BWZEU5yA.woff2 +0 -0
  243. holdspeak/static/_built/_astro/jetbrains-mono-latin-500-normal.CJOVTJB7.woff +0 -0
  244. holdspeak/static/_built/_astro/jetbrains-mono-latin-ext-400-normal.Bc8Ftmh3.woff2 +0 -0
  245. holdspeak/static/_built/_astro/jetbrains-mono-latin-ext-400-normal.fXTG6kC5.woff +0 -0
  246. holdspeak/static/_built/_astro/jetbrains-mono-latin-ext-500-normal.Cut-4mMH.woff2 +0 -0
  247. holdspeak/static/_built/_astro/jetbrains-mono-latin-ext-500-normal.ckzbgY84.woff +0 -0
  248. holdspeak/static/_built/_astro/jetbrains-mono-vietnamese-400-normal.CqNFfHCs.woff +0 -0
  249. holdspeak/static/_built/_astro/jetbrains-mono-vietnamese-500-normal.DNRqzVM1.woff +0 -0
  250. holdspeak/static/_built/_astro/journeyDiagram-JHISSGLW.WZS9VdE2.js +139 -0
  251. holdspeak/static/_built/_astro/kanban-definition-UN3LZRKU.CiBJq3eg.js +89 -0
  252. holdspeak/static/_built/_astro/katex.HP8lGamR.js +257 -0
  253. holdspeak/static/_built/_astro/layout.owoKPs3z.js +1 -0
  254. holdspeak/static/_built/_astro/linear.CnSHR4tB.js +1 -0
  255. holdspeak/static/_built/_astro/mermaid.core.Dk3RQ27w.js +303 -0
  256. holdspeak/static/_built/_astro/mindmap-definition-RKZ34NQL.D6ToJLIk.js +96 -0
  257. holdspeak/static/_built/_astro/module.esm.DLuZkmyn.js +5 -0
  258. holdspeak/static/_built/_astro/ordinal.BYWQX77i.js +1 -0
  259. holdspeak/static/_built/_astro/pieDiagram-4H26LBE5.Bjq6EVuj.js +30 -0
  260. holdspeak/static/_built/_astro/quadrantDiagram-W4KKPZXB.DWImXuIw.js +7 -0
  261. holdspeak/static/_built/_astro/requirementDiagram-4Y6WPE33.DcoqCJGO.js +84 -0
  262. holdspeak/static/_built/_astro/sankeyDiagram-5OEKKPKP.CF7MFHI6.js +40 -0
  263. holdspeak/static/_built/_astro/sequenceDiagram-3UESZ5HK.DOtyfyfd.js +162 -0
  264. holdspeak/static/_built/_astro/settings.DzjNJCZS.css +1 -0
  265. holdspeak/static/_built/_astro/settings.astro_astro_type_script_index_0_lang.BzMA_Ke9.js +243 -0
  266. holdspeak/static/_built/_astro/setup.BPUW9MXF.css +1 -0
  267. holdspeak/static/_built/_astro/setup.astro_astro_type_script_index_0_lang.C9WPmx9P.js +216 -0
  268. holdspeak/static/_built/_astro/space-grotesk-latin-500-normal.CNSSEhBt.woff +0 -0
  269. holdspeak/static/_built/_astro/space-grotesk-latin-500-normal.lFbtlQH6.woff2 +0 -0
  270. holdspeak/static/_built/_astro/space-grotesk-latin-600-normal.BflQw4A9.woff +0 -0
  271. holdspeak/static/_built/_astro/space-grotesk-latin-600-normal.DjKNqYRj.woff2 +0 -0
  272. holdspeak/static/_built/_astro/space-grotesk-latin-700-normal.CwsQ-cCU.woff +0 -0
  273. holdspeak/static/_built/_astro/space-grotesk-latin-700-normal.RjhwGPKo.woff2 +0 -0
  274. holdspeak/static/_built/_astro/space-grotesk-latin-ext-500-normal.3dgZTiw9.woff +0 -0
  275. holdspeak/static/_built/_astro/space-grotesk-latin-ext-500-normal.DUe3BAxM.woff2 +0 -0
  276. holdspeak/static/_built/_astro/space-grotesk-latin-ext-600-normal.DxxdqCpr.woff2 +0 -0
  277. holdspeak/static/_built/_astro/space-grotesk-latin-ext-600-normal.VcznFIpX.woff +0 -0
  278. holdspeak/static/_built/_astro/space-grotesk-latin-ext-700-normal.BQnZhY3m.woff2 +0 -0
  279. holdspeak/static/_built/_astro/space-grotesk-latin-ext-700-normal.HVCqSBdx.woff +0 -0
  280. holdspeak/static/_built/_astro/space-grotesk-vietnamese-500-normal.BTqKIpxg.woff +0 -0
  281. holdspeak/static/_built/_astro/space-grotesk-vietnamese-500-normal.BmEvtly_.woff2 +0 -0
  282. holdspeak/static/_built/_astro/space-grotesk-vietnamese-600-normal.D6zpsUhD.woff +0 -0
  283. holdspeak/static/_built/_astro/space-grotesk-vietnamese-600-normal.DUi7WF5p.woff2 +0 -0
  284. holdspeak/static/_built/_astro/space-grotesk-vietnamese-700-normal.DMty7AZE.woff2 +0 -0
  285. holdspeak/static/_built/_astro/space-grotesk-vietnamese-700-normal.Duxec5Rn.woff +0 -0
  286. holdspeak/static/_built/_astro/stateDiagram-AJRCARHV.05fu2X9t.js +1 -0
  287. holdspeak/static/_built/_astro/stateDiagram-v2-BHNVJYJU.DroQ3LdL.js +1 -0
  288. holdspeak/static/_built/_astro/timeline-definition-PNZ67QCA.CeLSEqD8.js +120 -0
  289. holdspeak/static/_built/_astro/vennDiagram-CIIHVFJN.DfWLwODp.js +34 -0
  290. holdspeak/static/_built/_astro/wardley-L42UT6IY.DO5nHI8o.js +161 -0
  291. holdspeak/static/_built/_astro/wardleyDiagram-YWT4CUSO.C6Y_RHFZ.js +78 -0
  292. holdspeak/static/_built/_astro/welcome.BIz1gKPz.css +1 -0
  293. holdspeak/static/_built/_astro/welcome.astro_astro_type_script_index_0_lang.C8G7sNS7.js +285 -0
  294. holdspeak/static/_built/_astro/xychartDiagram-2RQKCTM6.CtZMLnKX.js +7 -0
  295. holdspeak/static/_built/activity/index.html +123 -0
  296. holdspeak/static/_built/apple-touch-icon.png +0 -0
  297. holdspeak/static/_built/companion/index.html +118 -0
  298. holdspeak/static/_built/design/check/index.html +122 -0
  299. holdspeak/static/_built/design/components/index.html +184 -0
  300. holdspeak/static/_built/dictation/index.html +255 -0
  301. holdspeak/static/_built/docs/dictation-runtime/index.html +192 -0
  302. holdspeak/static/_built/favicon.svg +7 -0
  303. holdspeak/static/_built/history/index.html +129 -0
  304. holdspeak/static/_built/holdspeak-mark.png +0 -0
  305. holdspeak/static/_built/index.html +149 -0
  306. holdspeak/static/_built/presence/index.html +3 -0
  307. holdspeak/static/_built/settings/index.html +114 -0
  308. holdspeak/static/_built/setup/index.html +118 -0
  309. holdspeak/static/_built/welcome/index.html +8 -0
  310. holdspeak/target_profile.py +402 -0
  311. holdspeak/text_processor.py +134 -0
  312. holdspeak/tmux_transport.py +62 -0
  313. holdspeak/transcribe.py +371 -0
  314. holdspeak/typer.py +140 -0
  315. holdspeak/voice_typing.py +190 -0
  316. holdspeak/web/__init__.py +7 -0
  317. holdspeak/web/context.py +84 -0
  318. holdspeak/web/routes/__init__.py +31 -0
  319. holdspeak/web/routes/activity/__init__.py +45 -0
  320. holdspeak/web/routes/activity/candidates.py +228 -0
  321. holdspeak/web/routes/activity/enrichment.py +614 -0
  322. holdspeak/web/routes/activity/ledger.py +189 -0
  323. holdspeak/web/routes/activity/plugin_jobs.py +205 -0
  324. holdspeak/web/routes/activity/rules.py +176 -0
  325. holdspeak/web/routes/core.py +37 -0
  326. holdspeak/web/routes/dictation/__init__.py +61 -0
  327. holdspeak/web/routes/dictation/_helpers.py +710 -0
  328. holdspeak/web/routes/dictation/agent.py +201 -0
  329. holdspeak/web/routes/dictation/blocks.py +342 -0
  330. holdspeak/web/routes/dictation/intents.py +99 -0
  331. holdspeak/web/routes/dictation/kb.py +167 -0
  332. holdspeak/web/routes/dictation/pipeline.py +637 -0
  333. holdspeak/web/routes/dictation/project_docs.py +145 -0
  334. holdspeak/web/routes/meetings.py +1229 -0
  335. holdspeak/web/routes/pages.py +262 -0
  336. holdspeak/web/routes/projects.py +396 -0
  337. holdspeak/web/routes/setup.py +56 -0
  338. holdspeak/web/routes/system.py +960 -0
  339. holdspeak/web/runtime_support.py +79 -0
  340. holdspeak/web_auth.py +120 -0
  341. holdspeak/web_requests.py +163 -0
  342. holdspeak/web_runtime.py +2341 -0
  343. holdspeak/web_server.py +556 -0
  344. holdspeak-0.2.1.dist-info/METADATA +276 -0
  345. holdspeak-0.2.1.dist-info/RECORD +348 -0
  346. holdspeak-0.2.1.dist-info/WHEEL +4 -0
  347. holdspeak-0.2.1.dist-info/entry_points.txt +2 -0
  348. holdspeak-0.2.1.dist-info/licenses/LICENSE +201 -0
holdspeak/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """HoldSpeak - Voice typing for macOS and Linux. Hold, speak, release."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def _resolve_version() -> str:
7
+ """Return the one true version.
8
+
9
+ The package metadata written from `pyproject.toml` is the single source of
10
+ truth. An editable install (`uv pip install -e .`) registers that metadata,
11
+ so this resolves correctly for both installed and source-tree runs. The
12
+ fallback only matters when running from a raw checkout that was never
13
+ installed; there we read the version straight out of `pyproject.toml`.
14
+ """
15
+ try:
16
+ from importlib.metadata import PackageNotFoundError, version
17
+
18
+ try:
19
+ return version("holdspeak")
20
+ except PackageNotFoundError:
21
+ pass
22
+ except Exception:
23
+ pass
24
+
25
+ try:
26
+ import re
27
+ from pathlib import Path
28
+
29
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
30
+ text = pyproject.read_text(encoding="utf-8")
31
+ match = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"', text)
32
+ if match:
33
+ return match.group(1)
34
+ except Exception:
35
+ pass
36
+
37
+ return "0.0.0+unknown"
38
+
39
+
40
+ __version__ = _resolve_version()
@@ -0,0 +1,120 @@
1
+ """Local meeting-candidate extraction from activity records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Iterable, Optional
9
+ from urllib.parse import unquote
10
+
11
+ from .db import ActivityRecord
12
+
13
+ CALENDAR_CONNECTOR_ID = "calendar_activity"
14
+
15
+ CALENDAR_DOMAINS = frozenset(
16
+ {
17
+ "calendar.google.com",
18
+ "meet.google.com",
19
+ "outlook.live.com",
20
+ "outlook.office.com",
21
+ "outlook.office365.com",
22
+ "teams.microsoft.com",
23
+ }
24
+ )
25
+
26
+ DATE_TIME_RE = re.compile(
27
+ r"\b(?P<date>\d{4}-\d{2}-\d{2})[T ]+"
28
+ r"(?P<start>\d{1,2}:\d{2})"
29
+ r"(?:\s*(?:-|to)\s*(?P<end>\d{1,2}:\d{2}))?\b",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ActivityMeetingCandidatePreview:
36
+ """Preview of a meeting candidate derived from local activity only."""
37
+
38
+ title: str
39
+ starts_at: Optional[datetime]
40
+ ends_at: Optional[datetime]
41
+ meeting_url: str
42
+ source_activity_record_id: int
43
+ source_connector_id: str
44
+ confidence: float
45
+
46
+
47
+ def preview_calendar_meeting_candidates(
48
+ records: Iterable[ActivityRecord],
49
+ *,
50
+ source_connector_id: str = CALENDAR_CONNECTOR_ID,
51
+ limit: int = 50,
52
+ ) -> list[ActivityMeetingCandidatePreview]:
53
+ """Derive meeting-candidate previews from existing local activity records."""
54
+ previews: list[ActivityMeetingCandidatePreview] = []
55
+ for record in records:
56
+ if not _is_calendar_record(record):
57
+ continue
58
+ title = _candidate_title(record)
59
+ starts_at, ends_at = _candidate_time_hints(record)
60
+ previews.append(
61
+ ActivityMeetingCandidatePreview(
62
+ title=title,
63
+ starts_at=starts_at,
64
+ ends_at=ends_at,
65
+ meeting_url=record.url,
66
+ source_activity_record_id=record.id,
67
+ source_connector_id=source_connector_id,
68
+ confidence=_candidate_confidence(record),
69
+ )
70
+ )
71
+ if len(previews) >= max(1, min(int(limit), 500)):
72
+ break
73
+ return previews
74
+
75
+
76
+ def _is_calendar_record(record: ActivityRecord) -> bool:
77
+ domain = str(record.domain or "").strip().lower()
78
+ if domain in CALENDAR_DOMAINS:
79
+ return True
80
+ return any(domain.endswith(f".{candidate}") for candidate in CALENDAR_DOMAINS)
81
+
82
+
83
+ def _candidate_title(record: ActivityRecord) -> str:
84
+ title = str(record.title or "").strip()
85
+ if title:
86
+ return title
87
+ if "teams.microsoft.com" in record.domain:
88
+ return "Microsoft Teams meeting"
89
+ if "meet.google.com" in record.domain:
90
+ return "Google Meet meeting"
91
+ if "outlook" in record.domain:
92
+ return "Outlook calendar event"
93
+ return "Calendar event"
94
+
95
+
96
+ def _candidate_confidence(record: ActivityRecord) -> float:
97
+ title = str(record.title or "").lower()
98
+ if "meeting" in title or "calendar" in title:
99
+ return 0.75
100
+ if "teams.microsoft.com" in record.domain or "meet.google.com" in record.domain:
101
+ return 0.7
102
+ return 0.55
103
+
104
+
105
+ def _candidate_time_hints(record: ActivityRecord) -> tuple[Optional[datetime], Optional[datetime]]:
106
+ text = f"{record.title or ''} {unquote(record.url or '')}"
107
+ match = DATE_TIME_RE.search(text)
108
+ if match is None:
109
+ return None, None
110
+ try:
111
+ starts_at = datetime.fromisoformat(f"{match.group('date')}T{match.group('start')}:00")
112
+ end_text = match.group("end")
113
+ if not end_text:
114
+ return starts_at, None
115
+ ends_at = datetime.fromisoformat(f"{match.group('date')}T{end_text}:00")
116
+ if ends_at < starts_at:
117
+ ends_at += timedelta(days=1)
118
+ return starts_at, ends_at
119
+ except ValueError:
120
+ return None, None
@@ -0,0 +1,242 @@
1
+ """Shared dry-run harness for activity-enrichment connectors.
2
+
3
+ HS-9-13. Each known connector (gh, jira, calendar_activity) describes
4
+ what it *would* do via this harness without writing to the database.
5
+ The result shape is the same for every connector so the browser can
6
+ render a single dry-run preview surface.
7
+
8
+ Mutation-free guarantee:
9
+
10
+ - The harness only reads from the database (`list_activity_records`).
11
+ - It calls each connector's preview helper, which itself does not
12
+ mutate state (`preview_github_cli_enrichment`, `preview_jira_cli_enrichment`,
13
+ `preview_calendar_meeting_candidates`).
14
+ - It does not call any *_run_* helper.
15
+
16
+ Tests in `tests/integration/test_web_activity_api.py` assert that
17
+ the row counts of `activity_annotations` and
18
+ `activity_meeting_candidates` are unchanged after a dry-run.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Mapping, Optional
25
+
26
+ from .activity_candidates import (
27
+ CALENDAR_CONNECTOR_ID,
28
+ preview_calendar_meeting_candidates,
29
+ )
30
+ from .activity_connectors import KNOWN_CONNECTORS, get_descriptor
31
+ from .activity_github import (
32
+ CONNECTOR_ID as GH_CONNECTOR_ID,
33
+ SUPPORTED_ENTITY_TYPES as GH_SUPPORTED_ENTITY_TYPES,
34
+ preview_github_cli_enrichment,
35
+ )
36
+ from .activity_jira import (
37
+ CONNECTOR_ID as JIRA_CONNECTOR_ID,
38
+ SUPPORTED_ENTITY_TYPES as JIRA_SUPPORTED_ENTITY_TYPES,
39
+ preview_jira_cli_enrichment,
40
+ )
41
+ from .db import Database
42
+
43
+ DEFAULT_LIMIT = 25
44
+ MAX_LIMIT = 100
45
+
46
+ # A safety cap on the per-section length of the dry-run payload so a
47
+ # pathological local dataset cannot blow up the API response. Each
48
+ # section (commands / proposed_annotations / proposed_candidates) is
49
+ # truncated to this length and the result is flagged `truncated=True`.
50
+ PAYLOAD_SECTION_CAP = 100
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ConnectorDryRunResult:
55
+ """Uniform dry-run preview produced by `dry_run()`.
56
+
57
+ Every connector returns this shape. The browser renders the same
58
+ surface (commands → CommandPreview, proposed_* → list of cards,
59
+ warnings + permission_notes → inline messages) regardless of the
60
+ underlying connector kind.
61
+ """
62
+
63
+ connector_id: str
64
+ kind: str
65
+ capabilities: tuple[str, ...]
66
+ enabled: bool
67
+ cli_required: Optional[str]
68
+ cli_available: Optional[bool]
69
+ commands: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
70
+ proposed_annotations: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
71
+ proposed_candidates: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
72
+ warnings: tuple[str, ...] = field(default_factory=tuple)
73
+ permission_notes: tuple[str, ...] = field(default_factory=tuple)
74
+ truncated: bool = False
75
+
76
+ def to_payload(self) -> dict[str, Any]:
77
+ return {
78
+ "connector_id": self.connector_id,
79
+ "kind": self.kind,
80
+ "capabilities": list(self.capabilities),
81
+ "enabled": self.enabled,
82
+ "cli_required": self.cli_required,
83
+ "cli_available": self.cli_available,
84
+ "commands": list(self.commands),
85
+ "proposed_annotations": list(self.proposed_annotations),
86
+ "proposed_candidates": list(self.proposed_candidates),
87
+ "warnings": list(self.warnings),
88
+ "permission_notes": list(self.permission_notes),
89
+ "truncated": self.truncated,
90
+ }
91
+
92
+
93
+ class UnknownConnectorError(ValueError):
94
+ """Raised when `dry_run()` is given a connector id that isn't registered."""
95
+
96
+
97
+ def dry_run(
98
+ db: Database,
99
+ connector_id: str,
100
+ *,
101
+ limit: int = DEFAULT_LIMIT,
102
+ ) -> ConnectorDryRunResult:
103
+ """Build a uniform mutation-free preview for one connector.
104
+
105
+ Returns a `ConnectorDryRunResult`. The caller is responsible for
106
+ serializing it via `to_payload()`. The harness does not raise on
107
+ a disabled connector or a missing CLI — those land in
108
+ `permission_notes` so the browser can render the would-do plan
109
+ alongside the reason it can't currently run.
110
+ """
111
+ descriptor = get_descriptor(connector_id)
112
+ if descriptor is None:
113
+ raise UnknownConnectorError(connector_id)
114
+
115
+ capped_limit = max(1, min(int(limit), MAX_LIMIT))
116
+ state = db.activity.get_activity_enrichment_connector(descriptor.id)
117
+ enabled = bool(state.enabled) if state is not None else False
118
+
119
+ cli_required = descriptor.requires_cli
120
+ cli_status = descriptor.cli_status() if cli_required else None
121
+ cli_available = bool(cli_status.get("available")) if cli_status else None
122
+
123
+ permission_notes: list[str] = []
124
+ if not enabled:
125
+ permission_notes.append(
126
+ f"{descriptor.label} is currently disabled. Dry-run shows what "
127
+ "the connector would do if you enabled it; nothing runs."
128
+ )
129
+ if cli_required and cli_available is False:
130
+ permission_notes.append(
131
+ f"`{cli_required}` CLI was not found on PATH. Install and "
132
+ "authenticate it before enabling this connector."
133
+ )
134
+
135
+ warnings: list[str] = []
136
+ commands: list[Mapping[str, Any]] = []
137
+ proposed_annotations: list[Mapping[str, Any]] = []
138
+ proposed_candidates: list[Mapping[str, Any]] = []
139
+
140
+ if descriptor.id == GH_CONNECTOR_ID:
141
+ records = []
142
+ for entity_type in GH_SUPPORTED_ENTITY_TYPES:
143
+ records.extend(
144
+ db.activity.list_activity_records(entity_type=entity_type, limit=capped_limit)
145
+ )
146
+ records = records[:capped_limit]
147
+ if not records:
148
+ warnings.append(
149
+ "No GitHub PR or issue activity has been imported yet. "
150
+ "Visit a PR or issue page in your browser, then refresh "
151
+ "the activity ledger."
152
+ )
153
+ preview = preview_github_cli_enrichment(records, limit=capped_limit)
154
+ commands = list(preview.get("commands", []))
155
+ for command in commands:
156
+ proposed_annotations.append(
157
+ {
158
+ "annotation_type": command.get("annotation_type"),
159
+ "activity_record_id": command.get("activity_record_id"),
160
+ "entity_id": command.get("entity_id"),
161
+ "title": f"Local {command.get('annotation_type')} annotation",
162
+ "from_command": list(command.get("command", [])),
163
+ }
164
+ )
165
+
166
+ elif descriptor.id == JIRA_CONNECTOR_ID:
167
+ records = []
168
+ for entity_type in JIRA_SUPPORTED_ENTITY_TYPES:
169
+ records.extend(
170
+ db.activity.list_activity_records(entity_type=entity_type, limit=capped_limit)
171
+ )
172
+ records = records[:capped_limit]
173
+ if not records:
174
+ warnings.append(
175
+ "No Jira ticket activity has been imported yet. Visit "
176
+ "an Atlassian ticket in your browser, then refresh the "
177
+ "activity ledger."
178
+ )
179
+ preview = preview_jira_cli_enrichment(records, limit=capped_limit)
180
+ commands = list(preview.get("commands", []))
181
+ for command in commands:
182
+ proposed_annotations.append(
183
+ {
184
+ "annotation_type": command.get("annotation_type"),
185
+ "activity_record_id": command.get("activity_record_id"),
186
+ "entity_id": command.get("entity_id"),
187
+ "title": f"Local {command.get('annotation_type')} annotation",
188
+ "from_command": list(command.get("command", [])),
189
+ }
190
+ )
191
+
192
+ elif descriptor.id == CALENDAR_CONNECTOR_ID:
193
+ records = db.activity.list_activity_records(limit=max(capped_limit * 4, 50))
194
+ previews = preview_calendar_meeting_candidates(records, limit=capped_limit)
195
+ if not previews:
196
+ warnings.append(
197
+ "No calendar / video-call activity has been imported "
198
+ "yet. Visit a Google Calendar event, Outlook event, or "
199
+ "Meet/Teams link, then refresh the activity ledger."
200
+ )
201
+ for preview in previews:
202
+ proposed_candidates.append(
203
+ {
204
+ "title": preview.title,
205
+ "starts_at": preview.starts_at.isoformat() if preview.starts_at else None,
206
+ "ends_at": preview.ends_at.isoformat() if preview.ends_at else None,
207
+ "meeting_url": preview.meeting_url,
208
+ "source_activity_record_id": preview.source_activity_record_id,
209
+ "source_connector_id": preview.source_connector_id,
210
+ "confidence": preview.confidence,
211
+ }
212
+ )
213
+
214
+ truncated = False
215
+ if len(commands) > PAYLOAD_SECTION_CAP:
216
+ commands = commands[:PAYLOAD_SECTION_CAP]
217
+ truncated = True
218
+ if len(proposed_annotations) > PAYLOAD_SECTION_CAP:
219
+ proposed_annotations = proposed_annotations[:PAYLOAD_SECTION_CAP]
220
+ truncated = True
221
+ if len(proposed_candidates) > PAYLOAD_SECTION_CAP:
222
+ proposed_candidates = proposed_candidates[:PAYLOAD_SECTION_CAP]
223
+ truncated = True
224
+
225
+ return ConnectorDryRunResult(
226
+ connector_id=descriptor.id,
227
+ kind=descriptor.kind,
228
+ capabilities=descriptor.capabilities,
229
+ enabled=enabled,
230
+ cli_required=cli_required,
231
+ cli_available=cli_available,
232
+ commands=tuple(commands),
233
+ proposed_annotations=tuple(proposed_annotations),
234
+ proposed_candidates=tuple(proposed_candidates),
235
+ warnings=tuple(warnings),
236
+ permission_notes=tuple(permission_notes),
237
+ truncated=truncated,
238
+ )
239
+
240
+
241
+ def known_connector_ids() -> tuple[str, ...]:
242
+ return tuple(c.id for c in KNOWN_CONNECTORS)
@@ -0,0 +1,140 @@
1
+ """Pack-derived registry of activity connectors known to the runtime.
2
+
3
+ HS-13-01 + HS-13-04. The runtime registry now derives from
4
+ both first-party packs (`connector_packs.ALL_PACKS`) and any
5
+ user packs discovered under `~/.holdspeak/connector_packs/`
6
+ via `connector_pack_loader.build_registry`. The descriptor
7
+ surface is unchanged for downstream consumers; only the source
8
+ field on `ConnectorDescriptor` and the new
9
+ `reload_registry()`/`discovery_errors()` helpers are new.
10
+
11
+ `ConnectorDescriptor` carries:
12
+
13
+ - `id`, `label`, `kind`, `capabilities`, `requires_cli`,
14
+ `description` — sourced from the manifest.
15
+ - `source` — `"first-party"` or `"user"` so the API + doctor
16
+ can label each connector by provenance.
17
+ - `manifest` — the underlying `ConnectorManifest`.
18
+ - `cli_status()` — dispatches by id (gh / jira only).
19
+
20
+ The descriptor's `capabilities` is the manifest's
21
+ `capabilities` filtered to row-producing capabilities so the
22
+ manifest's `commands` / pure-preview capabilities don't leak
23
+ into API consumers expecting a row-shape.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from .activity_github import CONNECTOR_ID as GH_CONNECTOR_ID, github_cli_status
33
+ from .activity_jira import CONNECTOR_ID as JIRA_CONNECTOR_ID, jira_cli_status
34
+ from .connector_pack_loader import (
35
+ DiscoveryResult,
36
+ RegisteredPack,
37
+ build_registry,
38
+ )
39
+ from .connector_sdk import ConnectorManifest
40
+
41
+ _ROW_CAPABILITIES: frozenset[str] = frozenset({"records", "annotations", "candidates"})
42
+
43
+ ENRICHMENT_KINDS: frozenset[str] = frozenset(
44
+ {"cli_enrichment", "candidate_inference"}
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ConnectorDescriptor:
50
+ """Runtime view of one pack-registered connector."""
51
+
52
+ id: str
53
+ label: str
54
+ kind: str
55
+ capabilities: tuple[str, ...]
56
+ requires_cli: Optional[str]
57
+ description: str
58
+ source: str
59
+ manifest: ConnectorManifest
60
+
61
+ def cli_status(self) -> Optional[dict]:
62
+ if self.id == GH_CONNECTOR_ID:
63
+ return github_cli_status()
64
+ if self.id == JIRA_CONNECTOR_ID:
65
+ return jira_cli_status()
66
+ return None
67
+
68
+
69
+ def _descriptor_from_pack(pack: RegisteredPack) -> ConnectorDescriptor:
70
+ manifest = pack.manifest
71
+ return ConnectorDescriptor(
72
+ id=manifest.id,
73
+ label=manifest.label,
74
+ kind=manifest.kind,
75
+ capabilities=tuple(c for c in manifest.capabilities if c in _ROW_CAPABILITIES),
76
+ requires_cli=manifest.requires_cli,
77
+ description=manifest.description,
78
+ source=pack.source,
79
+ manifest=manifest,
80
+ )
81
+
82
+
83
+ # ───────────────────── Module-level registry state ────────────────────
84
+ #
85
+ # Populated at import time and refreshable via `reload_registry`.
86
+ # The web API + the dry-run harness + the fixture runner all
87
+ # read these globals; tests that exercise user-pack discovery
88
+ # call `reload_registry(user_packs_dir=tmp_path)` to swap them.
89
+
90
+ _DISCOVERY: DiscoveryResult = DiscoveryResult()
91
+ KNOWN_CONNECTORS: tuple[ConnectorDescriptor, ...] = ()
92
+ KNOWN_CONNECTOR_IDS: frozenset[str] = frozenset()
93
+
94
+
95
+ def _apply_discovery(result: DiscoveryResult) -> None:
96
+ global _DISCOVERY, KNOWN_CONNECTORS, KNOWN_CONNECTOR_IDS
97
+ _DISCOVERY = result
98
+ KNOWN_CONNECTORS = tuple(_descriptor_from_pack(p) for p in result.packs)
99
+ KNOWN_CONNECTOR_IDS = frozenset(c.id for c in KNOWN_CONNECTORS)
100
+
101
+
102
+ def reload_registry(
103
+ user_packs_dir: Optional[Path] = None,
104
+ ) -> DiscoveryResult:
105
+ """Recompute the registry. Returns the resulting
106
+ `DiscoveryResult` for callers that want to inspect errors.
107
+
108
+ Tests use this to swap in a tmp_path-scoped user-pack dir.
109
+ Production code calls it implicitly at module import; a
110
+ runtime restart re-discovers any new files dropped into
111
+ `~/.holdspeak/connector_packs/`.
112
+ """
113
+ result = build_registry(user_packs_dir=user_packs_dir)
114
+ _apply_discovery(result)
115
+ return result
116
+
117
+
118
+ def discovery_errors() -> tuple:
119
+ """Return the discovery errors from the most recent registry
120
+ load. Doctor + the API surface these so a malformed user
121
+ pack is visible without grepping logs."""
122
+ return _DISCOVERY.errors
123
+
124
+
125
+ def get_descriptor(connector_id: str) -> Optional[ConnectorDescriptor]:
126
+ for descriptor in KNOWN_CONNECTORS:
127
+ if descriptor.id == connector_id:
128
+ return descriptor
129
+ return None
130
+
131
+
132
+ def enrichment_descriptors() -> tuple[ConnectorDescriptor, ...]:
133
+ """Subset of the registry that drives the activity-enrichment
134
+ surface. Records-ingesters (firefox_ext) live in the registry
135
+ but not on this surface."""
136
+ return tuple(c for c in KNOWN_CONNECTORS if c.kind in ENRICHMENT_KINDS)
137
+
138
+
139
+ # Initial load at module-import time.
140
+ reload_registry()
@@ -0,0 +1,154 @@
1
+ """Shared activity context bundles for HoldSpeak plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from threading import Lock
10
+ from typing import Any, Optional
11
+
12
+ from .activity_history import import_browser_history
13
+ from .db import ActivityRecord, Database, get_database
14
+ from .logging_config import get_logger
15
+
16
+ log = get_logger("activity_context")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ActivityContextBundle:
21
+ """Serializable local activity context for plugin consumers."""
22
+
23
+ records: list[dict[str, Any]]
24
+ entity_counts: dict[str, int]
25
+ domain_counts: dict[str, int]
26
+ source_counts: dict[str, int]
27
+ generated_at: str
28
+ project_id: Optional[str] = None
29
+ refreshed: bool = False
30
+ refresh_errors: list[str] | None = None
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "records": list(self.records),
35
+ "entity_counts": dict(self.entity_counts),
36
+ "domain_counts": dict(self.domain_counts),
37
+ "source_counts": dict(self.source_counts),
38
+ "generated_at": self.generated_at,
39
+ "project_id": self.project_id,
40
+ "refreshed": self.refreshed,
41
+ "refresh_errors": list(self.refresh_errors or []),
42
+ }
43
+
44
+
45
+ class ActivityContextProvider:
46
+ """Callable context provider that injects local activity into plugins."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ db: Database | None = None,
52
+ limit: int = 20,
53
+ refresh: bool = False,
54
+ refresh_once: bool = True,
55
+ importer: Callable[..., Any] = import_browser_history,
56
+ ) -> None:
57
+ self._db = db
58
+ self._limit = max(1, min(int(limit), 200))
59
+ self._refresh = bool(refresh)
60
+ self._refresh_once = bool(refresh_once)
61
+ self._importer = importer
62
+ self._lock = Lock()
63
+ self._refreshed = False
64
+
65
+ def __call__(self, context: dict[str, Any]) -> dict[str, Any]:
66
+ project_id = _project_id_from_context(context)
67
+ bundle = build_activity_context(
68
+ db=self._db,
69
+ project_id=project_id,
70
+ limit=self._limit,
71
+ refresh=self._should_refresh(),
72
+ importer=self._importer,
73
+ )
74
+ return {"activity": bundle.to_dict()}
75
+
76
+ def _should_refresh(self) -> bool:
77
+ if not self._refresh:
78
+ return False
79
+ with self._lock:
80
+ if self._refresh_once and self._refreshed:
81
+ return False
82
+ self._refreshed = True
83
+ return True
84
+
85
+
86
+ def build_activity_context(
87
+ *,
88
+ db: Database | None = None,
89
+ project_id: Optional[str] = None,
90
+ limit: int = 20,
91
+ refresh: bool = False,
92
+ importer: Callable[..., Any] = import_browser_history,
93
+ ) -> ActivityContextBundle:
94
+ """Build a plugin-safe local activity context bundle."""
95
+ database = db or get_database()
96
+ refresh_errors: list[str] = []
97
+ did_refresh = False
98
+ if refresh:
99
+ try:
100
+ results = importer(db=database)
101
+ did_refresh = True
102
+ refresh_errors = [
103
+ str(result.error)
104
+ for result in results
105
+ if getattr(result, "error", None)
106
+ ]
107
+ except Exception as exc:
108
+ refresh_errors.append(f"{type(exc).__name__}: {exc}")
109
+ log.warning("Activity context refresh failed: %s", exc)
110
+
111
+ records = database.activity.list_activity_records(
112
+ project_id=project_id,
113
+ limit=max(1, min(int(limit), 200)),
114
+ )
115
+ serialized = [_serialize_activity_record(record) for record in records]
116
+ return ActivityContextBundle(
117
+ records=serialized,
118
+ entity_counts=dict(Counter(item["entity_type"] for item in serialized if item["entity_type"])),
119
+ domain_counts=dict(Counter(item["domain"] for item in serialized if item["domain"])),
120
+ source_counts=dict(Counter(item["source_browser"] for item in serialized if item["source_browser"])),
121
+ generated_at=datetime.now().isoformat(),
122
+ project_id=project_id,
123
+ refreshed=did_refresh,
124
+ refresh_errors=refresh_errors,
125
+ )
126
+
127
+
128
+ def _serialize_activity_record(record: ActivityRecord) -> dict[str, Any]:
129
+ return {
130
+ "id": record.id,
131
+ "source_browser": record.source_browser,
132
+ "source_profile": record.source_profile,
133
+ "url": record.url,
134
+ "title": record.title,
135
+ "domain": record.domain,
136
+ "visit_count": record.visit_count,
137
+ "first_seen_at": record.first_seen_at.isoformat() if record.first_seen_at else None,
138
+ "last_seen_at": record.last_seen_at.isoformat() if record.last_seen_at else None,
139
+ "entity_type": record.entity_type,
140
+ "entity_id": record.entity_id,
141
+ "project_id": record.project_id,
142
+ }
143
+
144
+
145
+ def _project_id_from_context(context: dict[str, Any]) -> Optional[str]:
146
+ raw_project_id = context.get("project_id")
147
+ if raw_project_id not in (None, ""):
148
+ return str(raw_project_id)
149
+ project = context.get("project")
150
+ if isinstance(project, dict):
151
+ raw_project_id = project.get("id") or project.get("project_id")
152
+ if raw_project_id not in (None, ""):
153
+ return str(raw_project_id)
154
+ return None