mednotes-opencode 0.1.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 (430) hide show
  1. package/.opencode/agents/med-chat-triager.md +204 -0
  2. package/.opencode/agents/med-flashcard-maker.md +63 -0
  3. package/.opencode/agents/med-knowledge-architect.md +230 -0
  4. package/.opencode/agents/med-link-graph-curator.md +177 -0
  5. package/.opencode/agents/med-publish-guard.md +62 -0
  6. package/.opencode/commands/flashcards.md +25 -0
  7. package/.opencode/commands/mednotes/create.md +25 -0
  8. package/.opencode/commands/mednotes/enrich.md +27 -0
  9. package/.opencode/commands/mednotes/fix-wiki.md +27 -0
  10. package/.opencode/commands/mednotes/history.md +22 -0
  11. package/.opencode/commands/mednotes/link-body.md +25 -0
  12. package/.opencode/commands/mednotes/link-related.md +27 -0
  13. package/.opencode/commands/mednotes/link.md +27 -0
  14. package/.opencode/commands/mednotes/pdf-library.md +27 -0
  15. package/.opencode/commands/mednotes/process-chats.md +23 -0
  16. package/.opencode/commands/mednotes/setup.md +21 -0
  17. package/.opencode/commands/mednotes/status.md +27 -0
  18. package/.opencode/commands/mednotes/telemetry.md +27 -0
  19. package/.opencode/commands/report.md +26 -0
  20. package/.opencode/mednotes/AGENTS.md +57 -0
  21. package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
  22. package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
  23. package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
  24. package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
  25. package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
  26. package/.opencode/mednotes/contracts/.gitkeep +1 -0
  27. package/.opencode/mednotes/contracts/agents.json +116 -0
  28. package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
  29. package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
  30. package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
  31. package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
  32. package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
  33. package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
  34. package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
  35. package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
  36. package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
  37. package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
  38. package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
  39. package/.opencode/mednotes/docs/extension-docs.md +40 -0
  40. package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
  41. package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
  42. package/.opencode/mednotes/docs/merge-policy.md +110 -0
  43. package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
  44. package/.opencode/mednotes/docs/semantic-linker.md +141 -0
  45. package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
  46. package/.opencode/mednotes/docs/triage-policy.md +187 -0
  47. package/.opencode/mednotes/docs/vault-version-control.md +758 -0
  48. package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
  49. package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
  50. package/.opencode/mednotes/hooks/hooks.json +79 -0
  51. package/.opencode/mednotes/package-lock.json +6361 -0
  52. package/.opencode/mednotes/package.json +15 -0
  53. package/.opencode/mednotes/pyproject.toml +48 -0
  54. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
  55. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
  56. package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
  57. package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
  58. package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
  59. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
  60. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
  61. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  62. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  63. package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
  64. package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
  65. package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
  66. package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  67. package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
  68. package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
  69. package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
  70. package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
  71. package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
  72. package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
  73. package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
  74. package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
  75. package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
  76. package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
  77. package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
  78. package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
  79. package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
  80. package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
  81. package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
  82. package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
  83. package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
  84. package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
  85. package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
  86. package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
  87. package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
  88. package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
  89. package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
  90. package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
  91. package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
  92. package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
  93. package/.opencode/mednotes/scripts/run_python.mjs +109 -0
  94. package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
  95. package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
  96. package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
  97. package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
  98. package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
  99. package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
  100. package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
  101. package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
  102. package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
  103. package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
  104. package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
  105. package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
  106. package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
  107. package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
  108. package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
  109. package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  110. package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  111. package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  112. package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
  113. package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
  114. package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
  115. package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
  116. package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
  117. package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
  118. package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
  119. package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
  120. package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
  121. package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
  122. package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
  123. package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
  124. package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
  125. package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
  126. package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
  127. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
  128. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
  129. package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
  130. package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
  131. package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
  132. package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
  133. package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
  134. package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
  135. package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
  136. package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
  137. package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
  138. package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
  139. package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
  140. package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
  141. package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
  142. package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
  143. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
  144. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
  145. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
  146. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
  147. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
  148. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
  149. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
  150. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
  151. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
  152. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
  153. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
  154. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
  155. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
  156. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
  157. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
  158. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
  159. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
  160. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
  161. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
  162. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
  163. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
  164. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
  165. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
  166. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
  167. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
  168. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
  169. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
  170. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
  171. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
  172. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
  173. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
  174. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
  175. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
  176. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
  177. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
  178. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
  179. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
  180. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
  181. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
  182. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
  183. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
  184. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
  185. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
  186. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
  187. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
  188. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
  189. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
  190. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
  191. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
  192. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
  193. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
  194. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
  195. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
  196. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
  197. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
  198. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
  199. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
  200. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
  201. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
  202. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
  203. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
  204. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
  205. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
  206. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
  207. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
  208. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
  209. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
  210. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
  211. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
  212. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
  213. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
  214. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
  215. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
  216. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
  217. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
  218. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
  219. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
  220. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
  221. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
  222. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
  223. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
  224. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
  225. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
  226. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
  227. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
  228. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
  229. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
  230. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
  231. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
  232. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
  233. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
  234. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
  235. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
  236. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
  237. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
  238. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
  239. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
  240. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
  241. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
  242. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
  243. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
  244. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
  245. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
  246. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
  247. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
  248. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
  249. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
  250. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
  251. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
  252. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
  253. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
  254. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
  255. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
  256. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
  257. package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
  258. package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
  259. package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
  260. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
  261. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
  262. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
  263. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
  264. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
  265. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
  266. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
  267. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
  268. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
  269. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
  270. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
  271. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
  272. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
  273. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
  274. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
  275. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
  276. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
  277. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
  278. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
  279. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
  280. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
  281. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
  282. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
  283. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
  284. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
  285. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
  286. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
  287. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
  288. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
  289. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
  290. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
  291. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
  292. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
  293. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
  294. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
  295. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
  296. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
  297. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
  298. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
  299. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
  300. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
  301. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
  302. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
  303. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
  304. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
  305. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
  306. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
  307. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
  308. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
  309. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
  310. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
  311. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
  312. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
  313. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
  314. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
  315. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
  316. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
  317. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
  318. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
  319. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
  320. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
  321. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
  322. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
  323. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
  324. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
  325. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
  326. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
  327. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
  328. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
  329. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
  330. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
  331. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
  332. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
  333. package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
  334. package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
  335. package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
  336. package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
  337. package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
  338. package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
  339. package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
  340. package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
  341. package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
  342. package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
  343. package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
  344. package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
  345. package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
  346. package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
  347. package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
  348. package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
  349. package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
  350. package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
  351. package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
  352. package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
  353. package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
  354. package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
  355. package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
  356. package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
  357. package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
  358. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
  359. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
  360. package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
  361. package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
  362. package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
  363. package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
  364. package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
  365. package/.opencode/mednotes/uv.lock +932 -0
  366. package/.opencode/mednotes.generated.json +395 -0
  367. package/.opencode/opencode.json +31 -0
  368. package/.opencode/plugins/mednotes-fsm.mjs +7 -0
  369. package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
  370. package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
  371. package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  372. package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  373. package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
  374. package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
  375. package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
  376. package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  377. package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
  378. package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
  379. package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
  380. package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
  381. package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
  382. package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
  383. package/AGENTS.md +57 -0
  384. package/README.md +194 -0
  385. package/adapters/antigravity/agents.json +80 -0
  386. package/adapters/antigravity/templates/med-chat-triager.md +214 -0
  387. package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
  388. package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
  389. package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
  390. package/adapters/antigravity/templates/med-publish-guard.md +71 -0
  391. package/adapters/gemini-cli/gemini-extension.json +14 -0
  392. package/adapters/gemini-cli/package.json +15 -0
  393. package/adapters/gemini-cli/pyproject.toml +48 -0
  394. package/bin/mednotes-opencode.mjs +155 -0
  395. package/contracts/agents.json +116 -0
  396. package/core/agents/med-chat-triager.md +197 -0
  397. package/core/agents/med-flashcard-maker.md +56 -0
  398. package/core/agents/med-knowledge-architect.md +224 -0
  399. package/core/agents/med-link-graph-curator.md +171 -0
  400. package/core/agents/med-publish-guard.md +55 -0
  401. package/core/commands/flashcards.toml +22 -0
  402. package/core/commands/mednotes/create.toml +22 -0
  403. package/core/commands/mednotes/enrich.toml +24 -0
  404. package/core/commands/mednotes/fix-wiki.toml +24 -0
  405. package/core/commands/mednotes/history.toml +19 -0
  406. package/core/commands/mednotes/link-body.toml +22 -0
  407. package/core/commands/mednotes/link-related.toml +24 -0
  408. package/core/commands/mednotes/link.toml +24 -0
  409. package/core/commands/mednotes/pdf-library.toml +24 -0
  410. package/core/commands/mednotes/process-chats.toml +20 -0
  411. package/core/commands/mednotes/setup.toml +18 -0
  412. package/core/commands/mednotes/status.toml +24 -0
  413. package/core/commands/mednotes/telemetry.toml +24 -0
  414. package/core/commands/report.toml +23 -0
  415. package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
  416. package/core/skills/create-medical-flashcards/SKILL.md +113 -0
  417. package/core/skills/create-medical-note/SKILL.md +90 -0
  418. package/core/skills/enrich-medical-note/SKILL.md +120 -0
  419. package/core/skills/fix-medical-wiki/SKILL.md +559 -0
  420. package/core/skills/link-medical-wiki/SKILL.md +224 -0
  421. package/core/skills/obsidian-cli/SKILL.md +118 -0
  422. package/core/skills/obsidian-markdown/SKILL.md +207 -0
  423. package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  424. package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  425. package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  426. package/core/skills/obsidian-ops/SKILL.md +136 -0
  427. package/core/skills/pdf-library/SKILL.md +45 -0
  428. package/core/skills/process-medical-chats/SKILL.md +246 -0
  429. package/core/skills/workflow-report/SKILL.md +100 -0
  430. package/package.json +45 -0
@@ -0,0 +1,875 @@
1
+ """Remote telemetry for workflow feedback records."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import platform
7
+ import re
8
+ import uuid
9
+ from importlib import metadata as importlib_metadata
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ import mednotes.platform.feedback.core as core
16
+ from mednotes.kernel.base import JsonObject, JsonObjectAdapter
17
+ from mednotes.platform.feedback.contracts import TelemetryStatusSnapshot
18
+ from mednotes.platform.feedback.telemetry_config import TelemetryConfig, TelemetrySection
19
+ from mednotes.platform.paths import default_config_path
20
+
21
+ TELEMETRY_ENVELOPE_SCHEMA = "medical-notes-workbench.workflow-telemetry-envelope.v1"
22
+ TELEMETRY_STATUS_SCHEMA = "medical-notes-workbench.workflow-telemetry-status.v1"
23
+ TELEMETRY_SENT_SCHEMA = "medical-notes-workbench.workflow-telemetry-sent.v1"
24
+ TRUSTED_DEBUG_PAYLOAD_LEVEL = "trusted_extension_debug"
25
+ PAYLOAD_LEVELS = {"diagnostic_redacted", "full_logs", TRUSTED_DEBUG_PAYLOAD_LEVEL}
26
+ DEFAULT_PAYLOAD_LEVEL = "diagnostic_redacted"
27
+ DEFAULT_MAX_ENVELOPE_BYTES = 256 * 1024
28
+ TRUSTED_DEBUG_MAX_ENVELOPE_BYTES = 1024 * 1024
29
+ DEFAULT_TIMEOUT_SECONDS = 5.0
30
+ CONFIG_ENV_VAR = "MEDNOTES_TELEMETRY_CONFIG"
31
+ DISABLED_ENV_VAR = "MEDNOTES_TELEMETRY_DISABLED"
32
+ DEFAULTS_ENV_VAR = "MEDNOTES_TELEMETRY_DEFAULTS"
33
+ DEFAULTS_DISABLED_ENV_VAR = "MEDNOTES_TELEMETRY_DEFAULTS_DISABLED"
34
+ DEFAULTS_FILE_NAME = "telemetry.defaults.json"
35
+ LOCAL_DEFAULTS_FILE_NAME = ".telemetry-defaults.json"
36
+ PROJECT_DISABLED_SOURCE = "project_disabled"
37
+ REMOTE_TELEMETRY_DISABLED_REASON = "remote_telemetry_disabled_by_project"
38
+ REMOTE_TELEMETRY_DISABLED = True
39
+
40
+
41
+ def telemetry_config_path(path: str | Path | None = None) -> Path:
42
+ if path:
43
+ return Path(os.path.expandvars(str(path))).expanduser()
44
+ override = os.getenv(CONFIG_ENV_VAR)
45
+ if override:
46
+ return Path(os.path.expandvars(override)).expanduser()
47
+ value = os.getenv("MEDNOTES_CONFIG")
48
+ if value:
49
+ return Path(os.path.expandvars(value)).expanduser()
50
+ return default_config_path()
51
+
52
+
53
+ def read_telemetry_config(path: str | Path | None = None) -> TelemetryConfig:
54
+ data = _read_config(path)
55
+ raw_section = data["telemetry"] if "telemetry" in data else {}
56
+ section = JsonObjectAdapter.validate_python(raw_section if isinstance(raw_section, dict) else {})
57
+ if REMOTE_TELEMETRY_DISABLED:
58
+ return _project_disabled_config(section)
59
+ defaults = _read_distribution_defaults()
60
+ if _should_apply_distribution_defaults(section, defaults):
61
+ section = _materialize_distribution_defaults(path, section, defaults or {})
62
+ return _config_from_section(section)
63
+
64
+
65
+ def _config_from_section(section: JsonObject) -> TelemetryConfig:
66
+ typed_section = TelemetrySection.from_payload(section)
67
+ payload_level = typed_section.payload_level
68
+ if payload_level not in PAYLOAD_LEVELS:
69
+ payload_level = DEFAULT_PAYLOAD_LEVEL
70
+ default_max_bytes = _default_max_envelope_bytes(payload_level)
71
+ max_bytes = typed_section.max_envelope_bytes or default_max_bytes
72
+ return TelemetryConfig(
73
+ enabled=typed_section.enabled,
74
+ endpoint_url=typed_section.endpoint_url,
75
+ auth_token=typed_section.auth_token,
76
+ payload_level=payload_level,
77
+ consent_at=typed_section.consent_at,
78
+ install_id=typed_section.install_id,
79
+ max_envelope_bytes=max(16 * 1024, min(2 * 1024 * 1024, max_bytes)),
80
+ source=typed_section.source,
81
+ auto_enabled_at=typed_section.auto_enabled_at,
82
+ opt_out_at=typed_section.opt_out_at,
83
+ defaults_path=typed_section.defaults_path,
84
+ )
85
+
86
+
87
+ def enable_telemetry(
88
+ *,
89
+ endpoint_url: str,
90
+ auth_token: str,
91
+ payload_level: str = DEFAULT_PAYLOAD_LEVEL,
92
+ config_path: str | Path | None = None,
93
+ ) -> dict[str, Any]:
94
+ endpoint_url = endpoint_url.strip()
95
+ auth_token = auth_token.strip()
96
+ if not endpoint_url.startswith(("https://", "http://")):
97
+ raise ValueError("--endpoint must be an http(s) URL")
98
+ if not auth_token:
99
+ raise ValueError("--token is required")
100
+ if payload_level not in PAYLOAD_LEVELS:
101
+ raise ValueError(f"--payload-level must be one of: {', '.join(sorted(PAYLOAD_LEVELS))}")
102
+ current = read_telemetry_config(config_path)
103
+ if REMOTE_TELEMETRY_DISABLED:
104
+ _write_telemetry_section(
105
+ config_path,
106
+ _project_disabled_values(
107
+ {
108
+ "payload_level": payload_level,
109
+ "install_id": current.install_id,
110
+ "opt_out_at": core.now_iso(),
111
+ }
112
+ ),
113
+ )
114
+ return telemetry_status(config_path=config_path)
115
+ max_envelope_bytes = current.max_envelope_bytes
116
+ if payload_level == TRUSTED_DEBUG_PAYLOAD_LEVEL:
117
+ max_envelope_bytes = max(max_envelope_bytes, TRUSTED_DEBUG_MAX_ENVELOPE_BYTES)
118
+ values = {
119
+ "enabled": True,
120
+ "endpoint_url": endpoint_url,
121
+ "auth_token": auth_token,
122
+ "payload_level": payload_level,
123
+ "consent_at": core.now_iso(),
124
+ "install_id": current.install_id or str(uuid.uuid4()),
125
+ "max_envelope_bytes": max_envelope_bytes,
126
+ "source": "user",
127
+ "auto_enabled_at": current.auto_enabled_at,
128
+ "opt_out_at": "",
129
+ "defaults_path": current.defaults_path,
130
+ }
131
+ _write_telemetry_section(config_path, values)
132
+ return telemetry_status(config_path=config_path)
133
+
134
+
135
+ def disable_telemetry(*, config_path: str | Path | None = None) -> dict[str, Any]:
136
+ current = read_telemetry_config(config_path)
137
+ source = PROJECT_DISABLED_SOURCE if REMOTE_TELEMETRY_DISABLED else "user_disabled"
138
+ values = _project_disabled_values(
139
+ {
140
+ "payload_level": current.payload_level,
141
+ "install_id": current.install_id,
142
+ "source": source,
143
+ "opt_out_at": core.now_iso(),
144
+ }
145
+ )
146
+ _write_telemetry_section(config_path, values)
147
+ return telemetry_status(config_path=config_path)
148
+
149
+
150
+ def telemetry_status(*, config_path: str | Path | None = None, root: str | Path | None = None) -> dict[str, Any]:
151
+ config = read_telemetry_config(config_path)
152
+ sent = _load_sent(root=root)
153
+ outbox_dir = _outbox_dir(root)
154
+ outbox_count = len(list(outbox_dir.glob("*.json"))) if outbox_dir.exists() else 0
155
+ recent_records = core.load_records(since="7d", root=root)[-5:]
156
+ recent_bundles = [
157
+ {
158
+ "run_id": str(record.get("run_id") or ""),
159
+ "workflow": str(record.get("workflow") or ""),
160
+ "bundle_id": str(
161
+ (record.get("telemetry_evidence") if isinstance(record.get("telemetry_evidence"), dict) else core.build_telemetry_evidence(record)).get("bundle_id")
162
+ ),
163
+ }
164
+ for record in recent_records
165
+ ]
166
+ recent_hook_errors = core.load_hook_errors(since="24h", root=root, limit=5)
167
+ recent_hook_events = core.load_hook_events(since="24h", root=root, limit=5)
168
+ pending_snapshots = core.load_pre_update_snapshot_records(since="30d", root=root, limit=5)
169
+ return TelemetryStatusSnapshot(
170
+ enabled=config.enabled,
171
+ ready=config.ready,
172
+ endpoint_url=_redact_endpoint(config.endpoint_url),
173
+ payload_level=config.payload_level,
174
+ consent_at=config.consent_at,
175
+ auto_enabled_at=config.auto_enabled_at,
176
+ opt_out_at=config.opt_out_at,
177
+ source=config.source,
178
+ install_id=config.install_id,
179
+ outbox_count=outbox_count,
180
+ sent_run_count=len(sent.get("sent_run_ids", [])),
181
+ config_path=str(telemetry_config_path(config_path)),
182
+ defaults_path=config.defaults_path,
183
+ recent_bundles=recent_bundles,
184
+ pending_pre_update_snapshot_count=len(pending_snapshots),
185
+ hook_health={
186
+ "recent_event_count": len(recent_hook_events),
187
+ "recent_error_count": len(recent_hook_errors),
188
+ "latest_error_types": [str(item.get("type") or "") for item in recent_hook_errors[:5]],
189
+ },
190
+ ).to_payload()
191
+
192
+
193
+ def preview_envelope(
194
+ *,
195
+ since: str = "30d",
196
+ limit: int = 20,
197
+ config_path: str | Path | None = None,
198
+ root: str | Path | None = None,
199
+ ) -> dict[str, Any]:
200
+ config = read_telemetry_config(config_path)
201
+ records = _records_for_envelope(since=since, limit=limit, root=root, config=config)
202
+ return build_envelope(records, config=config)
203
+
204
+
205
+ def send_telemetry(
206
+ *,
207
+ since: str = "30d",
208
+ limit: int = 20,
209
+ config_path: str | Path | None = None,
210
+ root: str | Path | None = None,
211
+ ) -> dict[str, Any]:
212
+ config = read_telemetry_config(config_path)
213
+ if not config.ready:
214
+ reason = REMOTE_TELEMETRY_DISABLED_REASON if config.source == PROJECT_DISABLED_SOURCE else "telemetry_not_enabled"
215
+ return {"ok": False, "sent": 0, "queued": 0, "reason": reason, "status": telemetry_status(config_path=config_path, root=root)}
216
+ first_flush = flush_outbox(config=config, root=root)
217
+ if first_flush.get("failed", 0):
218
+ first_flush["queued"] = 0
219
+ return first_flush
220
+ records = _records_for_envelope(since=since, limit=limit, root=root, config=config)
221
+ queued = 0
222
+ if records:
223
+ envelope = build_envelope(records, config=config)
224
+ _enqueue_envelope(envelope, root=root)
225
+ queued = 1
226
+ result = flush_outbox(config=config, root=root)
227
+ result["sent"] = int(result.get("sent", 0)) + int(first_flush.get("sent", 0))
228
+ result["queued"] = queued
229
+ return result
230
+
231
+
232
+ def safe_auto_send_record(record: dict[str, Any], *, raw_payload: Any = None, root: str | Path | None = None) -> dict[str, Any] | None:
233
+ if os.getenv(DISABLED_ENV_VAR) == "1":
234
+ return None
235
+ try:
236
+ config = read_telemetry_config()
237
+ if not config.ready:
238
+ return None
239
+ envelope = build_envelope([record], config=config, raw_payloads={str(record.get("run_id")): raw_payload})
240
+ _enqueue_envelope(envelope, root=root)
241
+ return flush_outbox(config=config, root=root, limit=3)
242
+ except Exception:
243
+ return None
244
+
245
+
246
+ def build_envelope(
247
+ records: list[dict[str, Any]],
248
+ *,
249
+ config: TelemetryConfig | None = None,
250
+ raw_payloads: dict[str, Any] | None = None,
251
+ ) -> dict[str, Any]:
252
+ config = config or read_telemetry_config()
253
+ raw_payloads = raw_payloads or {}
254
+ envelope = {
255
+ "schema": TELEMETRY_ENVELOPE_SCHEMA,
256
+ "envelope_id": str(uuid.uuid4()),
257
+ "generated_at": core.now_iso(),
258
+ "install_id": config.install_id,
259
+ "payload_level": config.payload_level,
260
+ "client": _client_context(),
261
+ "records": [
262
+ _telemetry_record(record, payload_level=config.payload_level, raw_payload=raw_payloads.get(str(record.get("run_id"))))
263
+ for record in records
264
+ ],
265
+ "limits": {
266
+ "max_envelope_bytes": config.max_envelope_bytes,
267
+ },
268
+ }
269
+ return _fit_envelope(envelope, max_bytes=config.max_envelope_bytes)
270
+
271
+
272
+ def flush_outbox(*, config: TelemetryConfig | None = None, root: str | Path | None = None, limit: int = 20) -> dict[str, Any]:
273
+ config = config or read_telemetry_config()
274
+ if not config.ready:
275
+ reason = REMOTE_TELEMETRY_DISABLED_REASON if config.source == PROJECT_DISABLED_SOURCE else "telemetry_not_enabled"
276
+ return {"ok": False, "sent": 0, "failed": 0, "reason": reason}
277
+ sent = 0
278
+ failed = 0
279
+ errors: list[str] = []
280
+ for path in sorted(_outbox_dir(root).glob("*.json"))[:limit]:
281
+ try:
282
+ envelope = json.loads(path.read_text(encoding="utf-8"))
283
+ _post_envelope(envelope, config=config)
284
+ _mark_sent(envelope, root=root)
285
+ path.unlink()
286
+ sent += 1
287
+ except Exception as exc:
288
+ failed += 1
289
+ errors.append(core.redact_snippet(str(exc)))
290
+ _bump_attempt(path)
291
+ return {"ok": failed == 0, "sent": sent, "failed": failed, "errors": errors[:5]}
292
+
293
+
294
+ def _read_config(path: str | Path | None = None) -> dict[str, Any]:
295
+ config_path = telemetry_config_path(path)
296
+ if not config_path.exists():
297
+ return {}
298
+ import tomllib
299
+
300
+ with config_path.open("rb") as fh:
301
+ return tomllib.load(fh)
302
+
303
+
304
+ def _write_telemetry_section(path: str | Path | None, values: dict[str, Any]) -> None:
305
+ config_path = telemetry_config_path(path)
306
+ config_path.parent.mkdir(parents=True, exist_ok=True)
307
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
308
+ section = _render_telemetry_section(values)
309
+ pattern = re.compile(r"(?ms)^\[telemetry\]\n.*?(?=^\[[^\n]+\]\s*$|\Z)")
310
+ if pattern.search(text):
311
+ updated = pattern.sub(section.rstrip() + "\n\n", text)
312
+ else:
313
+ updated = text.rstrip() + ("\n\n" if text.strip() else "") + section
314
+ tmp = config_path.with_suffix(config_path.suffix + ".tmp")
315
+ tmp.write_text(updated, encoding="utf-8")
316
+ tmp.replace(config_path)
317
+
318
+
319
+ def _render_telemetry_section(values: dict[str, Any]) -> str:
320
+ lines = ["[telemetry]"]
321
+ keys = (
322
+ "enabled",
323
+ "endpoint_url",
324
+ "auth_token",
325
+ "payload_level",
326
+ "consent_at",
327
+ "install_id",
328
+ "max_envelope_bytes",
329
+ "source",
330
+ "auto_enabled_at",
331
+ "opt_out_at",
332
+ "defaults_path",
333
+ )
334
+ for key in keys:
335
+ value = values.get(key)
336
+ if isinstance(value, bool):
337
+ rendered = "true" if value else "false"
338
+ elif isinstance(value, int):
339
+ rendered = str(value)
340
+ else:
341
+ if key.endswith("_path"):
342
+ value = str(value or "").replace("\\", "/")
343
+ rendered = json.dumps(str(value or ""), ensure_ascii=False)
344
+ lines.append(f"{key} = {rendered}")
345
+ return "\n".join(lines) + "\n"
346
+
347
+
348
+ def _project_disabled_values(section: dict[str, Any]) -> dict[str, Any]:
349
+ typed_section = TelemetrySection.from_payload(JsonObjectAdapter.validate_python(section))
350
+ payload_level = typed_section.payload_level
351
+ if payload_level not in PAYLOAD_LEVELS:
352
+ payload_level = DEFAULT_PAYLOAD_LEVEL
353
+ return {
354
+ "enabled": False,
355
+ "endpoint_url": "",
356
+ "auth_token": "",
357
+ "payload_level": payload_level,
358
+ "consent_at": "",
359
+ "install_id": typed_section.install_id,
360
+ "max_envelope_bytes": _coerce_max_envelope_bytes(typed_section.max_envelope_bytes, payload_level=payload_level),
361
+ "source": PROJECT_DISABLED_SOURCE,
362
+ "auto_enabled_at": "",
363
+ "opt_out_at": typed_section.opt_out_at,
364
+ "defaults_path": "",
365
+ }
366
+
367
+
368
+ def _project_disabled_config(section: JsonObject) -> TelemetryConfig:
369
+ values = _project_disabled_values({**section, "source": PROJECT_DISABLED_SOURCE})
370
+ return _config_from_section(values)
371
+
372
+
373
+ def _should_apply_distribution_defaults(section: dict[str, Any], defaults: dict[str, Any] | None) -> bool:
374
+ if not _distribution_defaults_ready(defaults):
375
+ return False
376
+ if section.get("enabled") is False and (
377
+ section.get("opt_out_at")
378
+ or section.get("consent_at")
379
+ or section.get("endpoint_url")
380
+ or section.get("auth_token")
381
+ ):
382
+ return False
383
+ if section.get("enabled") is True and section.get("endpoint_url") and section.get("auth_token"):
384
+ return _should_refresh_distribution_defaults(section, defaults or {})
385
+ return True
386
+
387
+
388
+ def _distribution_defaults_ready(defaults: dict[str, Any] | None) -> bool:
389
+ return bool(defaults and defaults.get("enabled") and defaults.get("endpoint_url") and defaults.get("auth_token"))
390
+
391
+
392
+ def _should_refresh_distribution_defaults(section: dict[str, Any], defaults: dict[str, Any]) -> bool:
393
+ if not _same_distribution_channel(section, defaults):
394
+ return False
395
+ desired_payload = _distribution_payload_level(section, defaults)
396
+ current_payload = str(section.get("payload_level") or DEFAULT_PAYLOAD_LEVEL)
397
+ if current_payload not in PAYLOAD_LEVELS:
398
+ current_payload = DEFAULT_PAYLOAD_LEVEL
399
+ desired_max = _coerce_max_envelope_bytes(defaults.get("max_envelope_bytes"), payload_level=desired_payload)
400
+ current_max = _coerce_max_envelope_bytes(section.get("max_envelope_bytes"), payload_level=current_payload)
401
+ return (
402
+ desired_payload != current_payload
403
+ or desired_max > current_max
404
+ or str(section.get("defaults_path") or "") != str(defaults.get("_path") or "")
405
+ )
406
+
407
+
408
+ def _same_distribution_channel(section: dict[str, Any], defaults: dict[str, Any]) -> bool:
409
+ if str(section.get("source") or "") == "distribution_default":
410
+ return True
411
+ if section.get("defaults_path"):
412
+ return True
413
+ return (
414
+ str(section.get("endpoint_url") or "") == str(defaults.get("endpoint_url") or "")
415
+ and str(section.get("auth_token") or "") == str(defaults.get("auth_token") or "")
416
+ )
417
+
418
+
419
+ def _distribution_payload_level(section: dict[str, Any], defaults: dict[str, Any]) -> str:
420
+ for value in (defaults.get("payload_level"), section.get("payload_level"), DEFAULT_PAYLOAD_LEVEL):
421
+ payload_level = str(value or "")
422
+ if payload_level in PAYLOAD_LEVELS:
423
+ return payload_level
424
+ return DEFAULT_PAYLOAD_LEVEL
425
+
426
+
427
+ def _materialize_distribution_defaults(
428
+ path: str | Path | None,
429
+ section: dict[str, Any],
430
+ defaults: dict[str, Any],
431
+ ) -> dict[str, Any]:
432
+ now = core.now_iso()
433
+ payload_level = _distribution_payload_level(section, defaults)
434
+ current_max = _coerce_max_envelope_bytes(section.get("max_envelope_bytes"), payload_level=payload_level)
435
+ default_max = _coerce_max_envelope_bytes(defaults.get("max_envelope_bytes"), payload_level=payload_level)
436
+ values = {
437
+ "enabled": True,
438
+ "endpoint_url": str(section.get("endpoint_url") or defaults.get("endpoint_url") or ""),
439
+ "auth_token": str(section.get("auth_token") or defaults.get("auth_token") or ""),
440
+ "payload_level": payload_level,
441
+ "consent_at": str(section.get("consent_at") or defaults.get("consent_at") or ""),
442
+ "install_id": str(section.get("install_id") or str(uuid.uuid4())),
443
+ "max_envelope_bytes": max(current_max, default_max),
444
+ "source": "distribution_default",
445
+ "auto_enabled_at": str(section.get("auto_enabled_at") or now),
446
+ "opt_out_at": "",
447
+ "defaults_path": str(defaults.get("_path") or ""),
448
+ }
449
+ _write_telemetry_section(path, values)
450
+ return values
451
+
452
+
453
+ def _default_max_envelope_bytes(payload_level: str) -> int:
454
+ return TRUSTED_DEBUG_MAX_ENVELOPE_BYTES if payload_level == TRUSTED_DEBUG_PAYLOAD_LEVEL else DEFAULT_MAX_ENVELOPE_BYTES
455
+
456
+
457
+ def _coerce_max_envelope_bytes(value: Any, *, payload_level: str = DEFAULT_PAYLOAD_LEVEL) -> int:
458
+ try:
459
+ parsed = int(value or _default_max_envelope_bytes(payload_level))
460
+ except (TypeError, ValueError):
461
+ parsed = _default_max_envelope_bytes(payload_level)
462
+ return max(16 * 1024, min(2 * 1024 * 1024, parsed))
463
+
464
+
465
+ def _read_distribution_defaults() -> dict[str, Any] | None:
466
+ if os.getenv(DEFAULTS_DISABLED_ENV_VAR) == "1":
467
+ return None
468
+ for path in _distribution_default_candidates():
469
+ try:
470
+ data = json.loads(path.read_text(encoding="utf-8"))
471
+ except (OSError, json.JSONDecodeError):
472
+ continue
473
+ if not isinstance(data, dict):
474
+ continue
475
+ telemetry = data.get("telemetry") if isinstance(data.get("telemetry"), dict) else data
476
+ if not isinstance(telemetry, dict):
477
+ continue
478
+ telemetry = dict(telemetry)
479
+ telemetry["_path"] = str(path)
480
+ return telemetry
481
+ return None
482
+
483
+
484
+ def _distribution_default_candidates() -> list[Path]:
485
+ override = os.getenv(DEFAULTS_ENV_VAR)
486
+ if override:
487
+ return [Path(os.path.expandvars(override)).expanduser()]
488
+ module_path = Path(__file__).resolve()
489
+ roots: list[Path] = []
490
+ for parent in module_path.parents:
491
+ if parent.name in {"src", "gemini-cli-extension", "medical-notes-workbench"}:
492
+ roots.append(parent.parent if parent.name == "src" else parent)
493
+ roots.append(module_path.parents[2])
494
+ seen: set[Path] = set()
495
+ candidates: list[Path] = []
496
+ for root in roots:
497
+ for name in (DEFAULTS_FILE_NAME, LOCAL_DEFAULTS_FILE_NAME):
498
+ candidate = root / name
499
+ if candidate not in seen:
500
+ seen.add(candidate)
501
+ candidates.append(candidate)
502
+ return candidates
503
+
504
+
505
+ def _unsent_records(*, since: str, limit: int, root: str | Path | None) -> list[dict[str, Any]]:
506
+ sent = set(_load_sent(root=root).get("sent_run_ids", []))
507
+ records = [record for record in core.load_records(since=since, root=root) if str(record.get("run_id")) not in sent]
508
+ return records[: max(1, limit)]
509
+
510
+
511
+ def _referenced_hook_ids(records: list[dict[str, Any]], key: str) -> set[str]:
512
+ referenced: set[str] = set()
513
+ for record in records:
514
+ values = record.get(key)
515
+ if isinstance(values, list):
516
+ referenced.update(str(value) for value in values if str(value))
517
+ return referenced
518
+
519
+
520
+ def _unreferenced_hook_debug_record(
521
+ *,
522
+ records: list[dict[str, Any]],
523
+ since: str,
524
+ root: str | Path | None,
525
+ sent_run_ids: set[str],
526
+ ) -> dict[str, Any] | None:
527
+ referenced_event_ids = _referenced_hook_ids(records, "hook_event_ids")
528
+ referenced_error_ids = _referenced_hook_ids(records, "hook_error_ids")
529
+ events = [
530
+ event
531
+ for event in core.load_hook_events(since=since, root=root, limit=100)
532
+ if str(event.get("event_id") or "") not in referenced_event_ids
533
+ ]
534
+ errors = [
535
+ error
536
+ for error in core.load_hook_errors(since=since, root=root, limit=50)
537
+ if str(error.get("error_id") or "") not in referenced_error_ids
538
+ ]
539
+ synthetic = core.hook_debug_record(events=events, errors=errors, since=since)
540
+ if not synthetic or str(synthetic.get("run_id")) in sent_run_ids:
541
+ return None
542
+ return synthetic
543
+
544
+
545
+ def _records_for_envelope(
546
+ *,
547
+ since: str,
548
+ limit: int,
549
+ root: str | Path | None,
550
+ config: TelemetryConfig,
551
+ ) -> list[dict[str, Any]]:
552
+ records = _unsent_records(since=since, limit=limit, root=root)
553
+ if config.payload_level == TRUSTED_DEBUG_PAYLOAD_LEVEL:
554
+ sent = set(_load_sent(root=root).get("sent_run_ids", []))
555
+ remaining = max(0, limit - len(records))
556
+ if remaining:
557
+ snapshots = [
558
+ record
559
+ for record in core.load_pre_update_snapshot_records(since=since, root=root, limit=remaining)
560
+ if str(record.get("run_id")) not in sent
561
+ ]
562
+ records.extend(snapshots)
563
+ if records:
564
+ synthetic = _unreferenced_hook_debug_record(
565
+ records=records,
566
+ since=since,
567
+ root=root,
568
+ sent_run_ids=sent,
569
+ )
570
+ if synthetic and len(records) < max(1, limit):
571
+ records.append(synthetic)
572
+ return records[: max(1, limit)]
573
+ if records or config.payload_level != TRUSTED_DEBUG_PAYLOAD_LEVEL:
574
+ return records
575
+ events = core.load_hook_events(since=since, root=root, limit=min(100, max(1, limit * 5)))
576
+ errors = core.load_hook_errors(since=since, root=root, limit=min(50, max(1, limit * 3)))
577
+ synthetic = core.hook_debug_record(events=events, errors=errors, since=since)
578
+ if not synthetic:
579
+ return []
580
+ sent = set(_load_sent(root=root).get("sent_run_ids", []))
581
+ return [] if str(synthetic.get("run_id")) in sent else [synthetic]
582
+
583
+
584
+ def _telemetry_record(record: dict[str, Any], *, payload_level: str, raw_payload: Any = None) -> dict[str, Any]:
585
+ payload_summary = record.get("payload_summary", {})
586
+ summary = payload_summary if isinstance(payload_summary, dict) else {}
587
+ diagnostic_context = record.get("diagnostic_context")
588
+ if not isinstance(diagnostic_context, dict):
589
+ diagnostic_context = core.build_diagnostic_context(
590
+ summary,
591
+ summary,
592
+ )
593
+ base = {
594
+ "run_id": record.get("run_id"),
595
+ "recorded_at": record.get("recorded_at"),
596
+ "workflow": record.get("workflow"),
597
+ "source": record.get("source"),
598
+ "exit_code": record.get("exit_code"),
599
+ "duration_ms": record.get("duration_ms"),
600
+ "status": record.get("status") or summary.get("status"),
601
+ "phase": record.get("phase") or summary.get("phase"),
602
+ "blocked_reason": record.get("blocked_reason") or summary.get("blocked_reason"),
603
+ "next_action": record.get("next_action") or summary.get("next_action"),
604
+ "required_inputs": record.get("required_inputs", []) or summary.get("required_inputs", []),
605
+ "human_decision_required": (
606
+ record.get("human_decision_required")
607
+ if record.get("human_decision_required") is not None
608
+ else summary.get("human_decision_required")
609
+ ),
610
+ "dry_run": record.get("dry_run") if record.get("dry_run") is not None else summary.get("dry_run"),
611
+ "apply": record.get("apply") if record.get("apply") is not None else summary.get("apply"),
612
+ "payload_summary": _telemetry_payload_summary(
613
+ summary,
614
+ payload_level=payload_level,
615
+ ),
616
+ "diagnostic_context": redact_object(diagnostic_context),
617
+ "agent_events": redact_object(record.get("agent_events", [])),
618
+ "environment_context": redact_object(record.get("environment_context", {})),
619
+ "diagnostic_snippets": record.get("diagnostic_snippets", []),
620
+ "telemetry_evidence": redact_object(core.build_telemetry_evidence(record, send_path="telemetry_envelope")),
621
+ }
622
+ if payload_level == "full_logs":
623
+ base["command"] = record.get("command", "")
624
+ base["extra"] = redact_object(record.get("extra", {}))
625
+ if raw_payload is not None:
626
+ base["raw_payload_redacted"] = redact_object(raw_payload)
627
+ else:
628
+ base["raw_payload_redacted"] = {"unavailable": True, "reason": "historical_record_has_no_raw_payload"}
629
+ if payload_level == TRUSTED_DEBUG_PAYLOAD_LEVEL:
630
+ integrity = record.get("environment_context", {}).get("extension_integrity", {}) if isinstance(record.get("environment_context"), dict) else {}
631
+ base["extension_diffs"] = _trusted_debug_object(record.get("extension_diffs") or integrity.get("extension_diffs", []))
632
+ base["generated_scripts"] = _trusted_debug_object(record.get("generated_scripts", []))
633
+ base["command_events"] = _trusted_debug_object(record.get("command_events", []))
634
+ base["hook_errors"] = _trusted_debug_object(record.get("hook_errors", []))
635
+ base["hook_event_ids"] = _operational_id_list(record.get("hook_event_ids", []))
636
+ base["hook_error_ids"] = _operational_id_list(record.get("hook_error_ids", []))
637
+ return base
638
+
639
+
640
+ def _operational_id_list(value: Any) -> list[str]:
641
+ if not isinstance(value, list):
642
+ return []
643
+ return [str(item) for item in value if str(item)]
644
+
645
+
646
+ def _trusted_debug_object(value: Any, *, depth: int = 0, key_context: str = "") -> Any:
647
+ if depth > 6:
648
+ return "[max-depth]"
649
+ if isinstance(value, dict):
650
+ out: dict[str, Any] = {}
651
+ for key, item in list(value.items())[:80]:
652
+ lower = str(key).lower()
653
+ if lower in {"token", "auth_token", "api_key", "apikey", "secret", "password", "authorization"}:
654
+ out[str(key)] = "[redacted]"
655
+ elif lower in {"html", "raw_chat", "note_text", "markdown"}:
656
+ out[str(key)] = f"[{lower} omitted]"
657
+ elif lower in {"patch", "content", "stdout_tail", "stderr_tail", "output_tail", "error", "command"} and isinstance(item, str):
658
+ out[str(key)] = core.redact_operational_text(item, max_chars=96 * 1024)
659
+ elif isinstance(item, str) and (_looks_like_hash_key(lower) or key_context == "path_hashes"):
660
+ out[str(key)] = item if _looks_like_hash_value(item) else core.redact_operational_text(item, max_chars=16 * 1024)
661
+ else:
662
+ out[str(key)] = _trusted_debug_object(item, depth=depth + 1, key_context=lower)
663
+ return out
664
+ if isinstance(value, list):
665
+ return [_trusted_debug_object(item, depth=depth + 1, key_context=key_context) for item in value[:100]]
666
+ if isinstance(value, str):
667
+ return core.redact_operational_text(value, max_chars=16 * 1024)
668
+ if isinstance(value, (int, float, bool)) or value is None:
669
+ return value
670
+ return core.redact_operational_text(str(value), max_chars=300)
671
+
672
+
673
+ def _looks_like_hash_key(key: str) -> bool:
674
+ lower = key.lower()
675
+ return "hash" in lower or "sha" in lower or "digest" in lower
676
+
677
+
678
+ def _looks_like_hash_value(value: str) -> bool:
679
+ return bool(re.fullmatch(r"[A-Fa-f0-9]{32,128}", value.strip()))
680
+
681
+
682
+ def _telemetry_payload_summary(summary: dict[str, Any], *, payload_level: str) -> dict[str, Any]:
683
+ if payload_level == TRUSTED_DEBUG_PAYLOAD_LEVEL:
684
+ value = _trusted_debug_object(summary)
685
+ return value if isinstance(value, dict) else {}
686
+ value = _redact_paths(summary)
687
+ return value if isinstance(value, dict) else {}
688
+
689
+
690
+ def redact_object(value: Any, *, depth: int = 0) -> Any:
691
+ if depth > 6:
692
+ return "[max-depth]"
693
+ if isinstance(value, dict):
694
+ out: dict[str, Any] = {}
695
+ for key, item in list(value.items())[:80]:
696
+ lower = str(key).lower()
697
+ if lower in {"token", "auth_token", "api_key", "apikey", "secret", "password", "authorization"}:
698
+ out[str(key)] = "[redacted]"
699
+ elif lower in {"content", "markdown", "html", "raw_chat", "note_text"} and isinstance(item, str):
700
+ out[str(key)] = core.redact_snippet(item, max_chars=240)
701
+ else:
702
+ out[str(key)] = redact_object(item, depth=depth + 1)
703
+ return out
704
+ if isinstance(value, list):
705
+ return [redact_object(item, depth=depth + 1) for item in value[:50]]
706
+ if isinstance(value, str):
707
+ return core.redact_snippet(value, max_chars=1200)
708
+ if isinstance(value, (int, float, bool)) or value is None:
709
+ return value
710
+ return core.redact_snippet(str(value), max_chars=300)
711
+
712
+
713
+ def _redact_paths(value: Any) -> Any:
714
+ if isinstance(value, dict):
715
+ out: dict[str, Any] = {}
716
+ for key, item in value.items():
717
+ if key == "relevant_paths" and isinstance(item, list):
718
+ out[key] = [_path_label(str(path)) for path in item]
719
+ elif key == "path_hashes" and isinstance(item, dict):
720
+ out[key] = {_path_label(str(path)): str(hash_value) for path, hash_value in item.items()}
721
+ else:
722
+ out[key] = _redact_paths(item)
723
+ return out
724
+ if isinstance(value, list):
725
+ return [_redact_paths(item) for item in value]
726
+ return value
727
+
728
+
729
+ def _path_label(path: str) -> str:
730
+ p = Path(path)
731
+ suffix = "/".join(p.parts[-3:]) if len(p.parts) >= 3 else p.name or path
732
+ return suffix.replace(str(Path.home()), "~")
733
+
734
+
735
+ def _fit_envelope(envelope: dict[str, Any], *, max_bytes: int) -> dict[str, Any]:
736
+ def size(data: dict[str, Any]) -> int:
737
+ return len(json.dumps(data, ensure_ascii=False, sort_keys=True).encode("utf-8"))
738
+
739
+ envelope["truncated"] = False
740
+ while size(envelope) > max_bytes and len(envelope.get("records", [])) > 1:
741
+ envelope["records"].pop()
742
+ envelope["truncated"] = True
743
+ if size(envelope) > max_bytes:
744
+ for record in envelope.get("records", []):
745
+ record.pop("raw_payload_redacted", None)
746
+ record["raw_payload_omitted"] = "envelope_size_limit"
747
+ envelope["truncated"] = True
748
+ return envelope
749
+
750
+
751
+ def _client_context() -> dict[str, Any]:
752
+ return {
753
+ "python": platform.python_version(),
754
+ "platform": platform.platform(),
755
+ "system": platform.system(),
756
+ "machine": platform.machine(),
757
+ "app": "medical-notes-workbench",
758
+ "app_version": _app_version(),
759
+ }
760
+
761
+
762
+ def _app_version() -> str:
763
+ try:
764
+ return importlib_metadata.version("medical-notes-workbench")
765
+ except importlib_metadata.PackageNotFoundError:
766
+ pass
767
+ for path in _version_file_candidates():
768
+ try:
769
+ text = path.read_text(encoding="utf-8")
770
+ except OSError:
771
+ continue
772
+ if path.name == "package.json":
773
+ try:
774
+ data = json.loads(text)
775
+ except json.JSONDecodeError:
776
+ continue
777
+ version = data.get("version") if isinstance(data, dict) else None
778
+ if version:
779
+ return str(version)
780
+ match = re.search(r'(?m)^version\s*=\s*["\']([^"\']+)["\']', text)
781
+ if match:
782
+ return match.group(1)
783
+ return "unknown"
784
+
785
+
786
+ def _version_file_candidates() -> list[Path]:
787
+ module_path = Path(__file__).resolve()
788
+ roots: list[Path] = []
789
+ for parent in module_path.parents:
790
+ if parent.name in {"src", "gemini-cli-extension", "medical-notes-workbench"}:
791
+ roots.append(parent.parent if parent.name == "src" else parent)
792
+ roots.append(module_path.parents[2])
793
+ seen: set[Path] = set()
794
+ candidates: list[Path] = []
795
+ for root in roots:
796
+ for name in ("pyproject.toml", "package.json"):
797
+ candidate = root / name
798
+ if candidate not in seen:
799
+ seen.add(candidate)
800
+ candidates.append(candidate)
801
+ return candidates
802
+
803
+
804
+ def _enqueue_envelope(envelope: dict[str, Any], *, root: str | Path | None = None) -> Path:
805
+ outbox = _outbox_dir(root)
806
+ outbox.mkdir(parents=True, exist_ok=True)
807
+ path = outbox / f"{envelope.get('generated_at', core.now_iso()).replace(':', '').replace('+', 'Z')}-{envelope['envelope_id']}.json"
808
+ envelope = {**envelope, "queued_at": core.now_iso(), "attempts": int(envelope.get("attempts", 0))}
809
+ core._atomic_write_json(path, envelope)
810
+ return path
811
+
812
+
813
+ def _outbox_dir(root: str | Path | None = None) -> Path:
814
+ return core.feedback_root(root) / "outbox"
815
+
816
+
817
+ def _sent_path(root: str | Path | None = None) -> Path:
818
+ return core.feedback_root(root) / "telemetry-sent.json"
819
+
820
+
821
+ def _load_sent(*, root: str | Path | None = None) -> dict[str, Any]:
822
+ path = _sent_path(root)
823
+ try:
824
+ data = json.loads(path.read_text(encoding="utf-8"))
825
+ except (OSError, json.JSONDecodeError):
826
+ return {"schema": TELEMETRY_SENT_SCHEMA, "sent_run_ids": []}
827
+ if not isinstance(data, dict):
828
+ return {"schema": TELEMETRY_SENT_SCHEMA, "sent_run_ids": []}
829
+ data["schema"] = TELEMETRY_SENT_SCHEMA
830
+ if not isinstance(data.get("sent_run_ids"), list):
831
+ data["sent_run_ids"] = []
832
+ return data
833
+
834
+
835
+ def _mark_sent(envelope: dict[str, Any], *, root: str | Path | None = None) -> None:
836
+ sent = _load_sent(root=root)
837
+ run_ids = {str(item) for item in sent.get("sent_run_ids", [])}
838
+ for record in envelope.get("records", []):
839
+ if isinstance(record, dict) and record.get("run_id"):
840
+ run_ids.add(str(record["run_id"]))
841
+ sent["sent_run_ids"] = sorted(run_ids)
842
+ sent["updated_at"] = core.now_iso()
843
+ path = _sent_path(root)
844
+ path.parent.mkdir(parents=True, exist_ok=True)
845
+ core._atomic_write_json(path, sent)
846
+
847
+
848
+ def _bump_attempt(path: Path) -> None:
849
+ try:
850
+ data = json.loads(path.read_text(encoding="utf-8"))
851
+ data["attempts"] = int(data.get("attempts", 0)) + 1
852
+ data["last_attempt_at"] = core.now_iso()
853
+ core._atomic_write_json(path, data)
854
+ except Exception:
855
+ pass
856
+
857
+
858
+ def _post_envelope(envelope: dict[str, Any], *, config: TelemetryConfig) -> None:
859
+ body = json.dumps(envelope, ensure_ascii=False, sort_keys=True).encode("utf-8")
860
+ if len(body) > config.max_envelope_bytes:
861
+ raise ValueError("telemetry envelope exceeds max_envelope_bytes")
862
+ headers = {
863
+ "Authorization": f"Bearer {config.auth_token}",
864
+ "Content-Type": "application/json",
865
+ "X-MedNotes-Telemetry-Schema": TELEMETRY_ENVELOPE_SCHEMA,
866
+ }
867
+ with httpx.Client(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
868
+ response = client.post(config.endpoint_url, content=body, headers=headers)
869
+ response.raise_for_status()
870
+
871
+
872
+ def _redact_endpoint(url: str) -> str:
873
+ if not url:
874
+ return ""
875
+ return re.sub(r"([?&](?:token|key|secret)=)[^&]+", r"\1[redacted]", url)