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,1186 @@
1
+ """Headless Related Notes export compatible with the Obsidian plugin contract."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ import time
10
+ from collections.abc import Callable, Iterable
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ import httpx
17
+ from pydantic import ValidationError
18
+
19
+ from mednotes.domains.wiki.capabilities.notes.provenance import CHAT_ORIGINAL_LABEL
20
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
21
+ from mednotes.domains.wiki.contracts.related_notes import RelatedNotesExportNote, RelatedNotesHashMigrationExport
22
+ from mednotes.domains.wiki.contracts.related_notes_headless import (
23
+ GeminiBatchEmbeddingResponse,
24
+ GeminiEmbedding,
25
+ GeminiEmbeddingResponse,
26
+ GeminiErrorResponse,
27
+ RelatedNotesHashMigrationCache,
28
+ RelatedNotesHeadlessSettings,
29
+ RelatedNotesVectorIndex,
30
+ RelatedNotesVectorRecord,
31
+ )
32
+ from mednotes.domains.wiki.performance import cooperative_cpu_yield
33
+ from mednotes.kernel.base import JsonObjectAdapter, JsonValue
34
+ from mednotes.platform.paths import user_state_dir
35
+
36
+ RELATED_NOTES_EXPORT_SCHEMA = "medical-notes-workbench.related-notes-export.v1"
37
+ RELATED_NOTES_HASH_MIGRATION_CACHE_SCHEMA = "medical-notes-workbench.related-notes-hash-migration-cache.v1"
38
+ PLUGIN_ID = "related-notes-obsidian"
39
+ PLUGIN_EXPORT_NAME = "medical-notes-export.json"
40
+ PLUGIN_INDEX_NAME = "index.json"
41
+ PLUGIN_SETTINGS_NAME = "data.json"
42
+ DEFAULT_EMBEDDING_MODEL = "gemini-embedding-001"
43
+ DEFAULT_PROFILE_ID = "clean_v1"
44
+ PROFILE_VERSION = 1
45
+ MAX_EMBEDDING_CHARS = 12000
46
+ DEFAULT_BATCH_SIZE = 32
47
+ MIN_EMBEDDING_REQUEST_DELAY_SECONDS = 10.0
48
+ DEFAULT_MAX_EMBEDDING_SECONDS = 120.0
49
+ TRANSIENT_EMBEDDING_RETRY_LIMIT = 3
50
+
51
+ EmbeddingClient = Callable[..., list[list[float]]]
52
+ SleepFn = Callable[[float], None]
53
+ ClockFn = Callable[[], float]
54
+
55
+ _CODE_BLOCK_RE = re.compile(r"```[\s\S]*?```")
56
+ _FRONTMATTER_YAML_RE = re.compile(r"^---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)")
57
+ _FRONTMATTER_TOML_RE = re.compile(r"^\+\+\+\r?\n[\s\S]*?\r?\n\+\+\+\s*(?:\r?\n|$)")
58
+ _RELATED_HEADING_RE = re.compile(r"(?m)^##\s+(?:🔗\s+)?Notas Relacionadas\s*$")
59
+ _NEXT_H2_RE = re.compile(r"(?m)^##\s+")
60
+ _GENERATED_FOOTER_RE = re.compile(
61
+ rf"\n---\s*\n(?:\[[^\]]*{re.escape(CHAT_ORIGINAL_LABEL)}[^\]]*\]\([^)]+\)|{re.escape(CHAT_ORIGINAL_LABEL)}\b|Gerado|Generated|Exportado|Fonte|Source|Criado|Created)[\s\S]*$",
62
+ re.IGNORECASE,
63
+ )
64
+ _COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
65
+ _OBSIDIAN_IMAGE_RE = re.compile(r"!\[\[[^\]]+\]\]")
66
+ _MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)")
67
+ _WIKILINK_ALIAS_RE = re.compile(r"\[\[([^\]|]+)\|([^\]]+)\]\]")
68
+ _WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
69
+ _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
70
+
71
+
72
+ @dataclass
73
+ class HeadlessRelatedNotesExportError(Exception):
74
+ blocked_reason: str
75
+ next_action: str
76
+ detail: str = ""
77
+ partial_record_count: int = 0
78
+ fresh_record_count: int = 0
79
+ stale_record_count: int = 0
80
+ record_count: int = 0
81
+ embedded_count: int = 0
82
+ reused_count: int = 0
83
+ total_note_count: int = 0
84
+ remaining_count: int = 0
85
+ next_retry_after_seconds: int = 0
86
+
87
+ def __str__(self) -> str:
88
+ return self.detail or self.blocked_reason
89
+
90
+
91
+ class BatchEmbeddingUnavailable(RuntimeError):
92
+ """Raised when the Gemini batch embedding endpoint cannot handle the request."""
93
+
94
+ def __init__(self, message: str, *, rate_limited: bool = False) -> None:
95
+ super().__init__(message)
96
+ self.rate_limited = rate_limited
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class _MarkdownNote:
101
+ rel_path: str
102
+ abs_path: Path
103
+ title: str
104
+ markdown: str
105
+ raw_hash: str
106
+ representation: str
107
+ representation_hash: str
108
+
109
+
110
+ def generate_headless_related_notes_export(
111
+ wiki_dir: Path,
112
+ *,
113
+ export_path: Path | None = None,
114
+ settings_path: Path | None = None,
115
+ index_path: Path | None = None,
116
+ embedding_client: EmbeddingClient | None = None,
117
+ sleep: SleepFn | None = None,
118
+ now_iso: str | None = None,
119
+ now_ms: int | None = None,
120
+ batch_size: int = DEFAULT_BATCH_SIZE,
121
+ max_embedding_seconds: float | None = None,
122
+ monotonic: ClockFn | None = None,
123
+ ) -> dict[str, Any]:
124
+ """Rebuild the plugin index and Workbench export without opening Obsidian."""
125
+ wiki = wiki_dir.resolve(strict=False)
126
+ plugin_dir = wiki / ".obsidian" / "plugins" / PLUGIN_ID
127
+ settings_file = settings_path or plugin_dir / PLUGIN_SETTINGS_NAME
128
+ export_file = export_path or plugin_dir / PLUGIN_EXPORT_NAME
129
+ index_file = index_path or plugin_dir / PLUGIN_INDEX_NAME
130
+ settings = _load_settings(settings_file)
131
+ settings_model = RelatedNotesHeadlessSettings.model_validate(settings)
132
+ api_key = settings_model.gemini_api_key.strip()
133
+ if not api_key:
134
+ raise HeadlessRelatedNotesExportError(
135
+ blocked_reason="related_notes_headless_api_key_missing",
136
+ next_action="Configurar a chave do plugin Related Notes e repetir a recuperação do export.",
137
+ detail="Related Notes plugin data.json exists but geminiApiKey is empty.",
138
+ )
139
+
140
+ profile_id = _profile_id(settings_model.default_embedding_profile)
141
+ related_limit = _related_limit(settings_model.related_notes_limit)
142
+ delay_seconds = _delay_seconds(settings_model.embedding_request_delay_ms)
143
+ notes = _load_markdown_notes(wiki, profile_id)
144
+ existing_index = _load_vector_index(index_file)
145
+ records = _current_records(existing_index, profile_id)
146
+ reused_count = 0
147
+ missing: list[_MarkdownNote] = []
148
+ index_dirty = False
149
+ for note in notes:
150
+ record = records.get(note.rel_path)
151
+ if _record_is_current(record, note, profile_id):
152
+ reused_count += 1
153
+ continue
154
+ if _record_is_legacy_clean_v1_current(record, note, profile_id):
155
+ vector = _migration_vector(record, note, profile_id)
156
+ if vector is None:
157
+ missing.append(note)
158
+ continue
159
+ records[note.rel_path] = _record(
160
+ note,
161
+ vector,
162
+ profile_id=profile_id,
163
+ updated_at=now_ms if now_ms is not None else _now_ms(),
164
+ )
165
+ reused_count += 1
166
+ index_dirty = True
167
+ continue
168
+ missing.append(note)
169
+
170
+ embedded_count = 0
171
+ using_default_client = embedding_client is None
172
+ client = embedding_client or _default_embedding_client
173
+ sleeper = sleep or time.sleep
174
+ clock = monotonic or time.monotonic
175
+ started_at = clock()
176
+ embedding_time_budget = _max_embedding_seconds(max_embedding_seconds)
177
+ normalized_batch_size = max(1, int(batch_size or DEFAULT_BATCH_SIZE))
178
+ transient_retry_count = 0
179
+
180
+ def flush_vector_index() -> None:
181
+ nonlocal existing_index, index_dirty
182
+ timestamp = now_ms if now_ms is not None else _now_ms()
183
+ vector_index = _vector_index(
184
+ existing_index,
185
+ records,
186
+ profile_id=profile_id,
187
+ updated_at=timestamp,
188
+ )
189
+ index_file.parent.mkdir(parents=True, exist_ok=True)
190
+ atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
191
+ existing_index = vector_index
192
+ index_dirty = False
193
+
194
+ def time_budget_exhausted() -> bool:
195
+ return embedding_time_budget > 0 and (clock() - started_at) >= embedding_time_budget
196
+
197
+ def raise_time_budget_exhausted() -> None:
198
+ progress = _recovery_progress_counts(
199
+ records=records,
200
+ notes=notes,
201
+ reused_count=reused_count,
202
+ embedded_count=embedded_count,
203
+ )
204
+ raise HeadlessRelatedNotesExportError(
205
+ blocked_reason="related_notes_headless_time_budget_exhausted",
206
+ next_action="Retomar a recuperação do Related Notes pela rota oficial; o índice parcial será reaproveitado.",
207
+ detail="Related Notes headless export paused after reaching the execution time budget.",
208
+ partial_record_count=progress["fresh_record_count"],
209
+ fresh_record_count=progress["fresh_record_count"],
210
+ stale_record_count=progress["stale_record_count"],
211
+ record_count=progress["record_count"],
212
+ embedded_count=embedded_count,
213
+ reused_count=reused_count,
214
+ total_note_count=len(notes),
215
+ remaining_count=progress["remaining_count"],
216
+ next_retry_after_seconds=int(math.ceil(delay_seconds)),
217
+ )
218
+
219
+ try:
220
+ index = 0
221
+ while index < len(missing):
222
+ if embedded_count and time_budget_exhausted():
223
+ raise_time_budget_exhausted()
224
+ batch = missing[index : index + normalized_batch_size]
225
+ batch_transient_retries = 0
226
+ try:
227
+ while True:
228
+ try:
229
+ vectors = client(
230
+ [note.representation for note in batch],
231
+ api_key=api_key,
232
+ model=DEFAULT_EMBEDDING_MODEL,
233
+ )
234
+ break
235
+ except httpx.TransportError:
236
+ if not using_default_client or batch_transient_retries >= TRANSIENT_EMBEDDING_RETRY_LIMIT:
237
+ raise
238
+ batch_transient_retries += 1
239
+ transient_retry_count += 1
240
+ sleeper(delay_seconds)
241
+ except BatchEmbeddingUnavailable as exc:
242
+ if using_default_client and normalized_batch_size > 1:
243
+ if exc.rate_limited and delay_seconds:
244
+ sleeper(delay_seconds)
245
+ normalized_batch_size = max(1, len(batch) // 2) if exc.rate_limited else 1
246
+ continue
247
+ raise
248
+ if len(vectors) != len(batch):
249
+ raise HeadlessRelatedNotesExportError(
250
+ blocked_reason="related_notes_headless_embedding_failed",
251
+ next_action="Repetir a recuperação; o provedor de embeddings retornou contagem inconsistente.",
252
+ detail="Embedding response count did not match request count.",
253
+ )
254
+ timestamp = now_ms if now_ms is not None else _now_ms()
255
+ for note, vector in zip(batch, vectors, strict=True):
256
+ records[note.rel_path] = _record(note, vector, profile_id=profile_id, updated_at=timestamp)
257
+ embedded_count += 1
258
+ index_dirty = True
259
+ if index_dirty and embedded_count % 10 == 0:
260
+ flush_vector_index()
261
+ if embedded_count < len(missing) and time_budget_exhausted():
262
+ raise_time_budget_exhausted()
263
+ if delay_seconds and embedded_count < len(missing):
264
+ sleeper(delay_seconds)
265
+ index += len(batch)
266
+ except HeadlessRelatedNotesExportError as exc:
267
+ if index_dirty:
268
+ flush_vector_index()
269
+ progress = _recovery_progress_counts(
270
+ records=records,
271
+ notes=notes,
272
+ reused_count=reused_count,
273
+ embedded_count=embedded_count,
274
+ )
275
+ exc.record_count = exc.record_count or progress["record_count"]
276
+ exc.fresh_record_count = exc.fresh_record_count or progress["fresh_record_count"]
277
+ exc.stale_record_count = exc.stale_record_count or progress["stale_record_count"]
278
+ exc.partial_record_count = exc.partial_record_count or progress["fresh_record_count"]
279
+ exc.embedded_count = exc.embedded_count or embedded_count
280
+ exc.reused_count = exc.reused_count or reused_count
281
+ exc.total_note_count = exc.total_note_count or len(notes)
282
+ exc.remaining_count = exc.remaining_count or progress["remaining_count"]
283
+ exc.next_retry_after_seconds = exc.next_retry_after_seconds or int(math.ceil(delay_seconds))
284
+ raise
285
+ except Exception as exc:
286
+ if index_dirty:
287
+ flush_vector_index()
288
+ progress = _recovery_progress_counts(
289
+ records=records,
290
+ notes=notes,
291
+ reused_count=reused_count,
292
+ embedded_count=embedded_count,
293
+ )
294
+ raise HeadlessRelatedNotesExportError(
295
+ blocked_reason="related_notes_headless_embedding_failed",
296
+ next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
297
+ detail=_redact_error(str(exc)),
298
+ partial_record_count=progress["fresh_record_count"],
299
+ fresh_record_count=progress["fresh_record_count"],
300
+ stale_record_count=progress["stale_record_count"],
301
+ record_count=progress["record_count"],
302
+ embedded_count=embedded_count,
303
+ reused_count=reused_count,
304
+ total_note_count=len(notes),
305
+ remaining_count=progress["remaining_count"],
306
+ next_retry_after_seconds=int(math.ceil(delay_seconds)),
307
+ ) from exc
308
+
309
+ timestamp = now_ms if now_ms is not None else _now_ms()
310
+ vector_index = _vector_index(existing_index, records, profile_id=profile_id, updated_at=timestamp)
311
+ payload = _export_payload(
312
+ notes,
313
+ records,
314
+ wiki,
315
+ profile_id=profile_id,
316
+ related_limit=related_limit,
317
+ generated_at=now_iso or _now_iso(),
318
+ )
319
+ index_file.parent.mkdir(parents=True, exist_ok=True)
320
+ export_file.parent.mkdir(parents=True, exist_ok=True)
321
+ atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
322
+ atomic_write_text(export_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
323
+ return {
324
+ "schema": "medical-notes-workbench.related-notes-headless-export.v1",
325
+ "status": "completed",
326
+ "phase": "related_notes_headless_export",
327
+ "export_path": str(export_file),
328
+ "index_path": str(index_file),
329
+ "wiki_dir": str(wiki),
330
+ "note_count": len(payload["notes"]),
331
+ "edge_count": len(payload["edges"]),
332
+ "record_count": len(records),
333
+ "fresh_record_count": len(notes),
334
+ "stale_record_count": max(0, len(records) - len(notes)),
335
+ "remaining_count": 0,
336
+ "embedded_count": embedded_count,
337
+ "reused_count": reused_count,
338
+ "embedding_model": DEFAULT_EMBEDDING_MODEL,
339
+ "embedding_profile_id": profile_id,
340
+ "embedding_request_delay_ms": int(delay_seconds * 1000),
341
+ "embedding_transient_retry_count": transient_retry_count,
342
+ "related_notes_limit": related_limit,
343
+ }
344
+
345
+
346
+ def _related_notes_hash_migration_cache_path() -> Path:
347
+ return user_state_dir() / "related-notes-hash-migration-cache.json"
348
+
349
+
350
+ def _related_notes_hash_migration_file_identity(path: Path) -> dict[str, object] | None:
351
+ try:
352
+ stat = path.stat()
353
+ except OSError:
354
+ return None
355
+ return {
356
+ "path": str(path.resolve(strict=False)),
357
+ "size": stat.st_size,
358
+ "mtime_ns": stat.st_mtime_ns,
359
+ }
360
+
361
+
362
+ def _related_notes_hash_migration_cache_identity(
363
+ *,
364
+ export_file: Path,
365
+ index_file: Path,
366
+ wiki_dir: Path,
367
+ ) -> dict[str, object] | None:
368
+ export_identity = _related_notes_hash_migration_file_identity(export_file)
369
+ index_identity = _related_notes_hash_migration_file_identity(index_file)
370
+ if export_identity is None or index_identity is None:
371
+ return None
372
+ return {
373
+ "wiki_dir": str(wiki_dir.resolve(strict=False)),
374
+ "export": export_identity,
375
+ "index": index_identity,
376
+ "profile_id": "clean_v1",
377
+ "profile_version": PROFILE_VERSION,
378
+ "migration": "clean_v1_table_hashes",
379
+ }
380
+
381
+
382
+ def _related_notes_hash_migration_cache_hit(
383
+ *,
384
+ export_file: Path,
385
+ index_file: Path,
386
+ wiki_dir: Path,
387
+ ) -> dict[str, object] | None:
388
+ identity = _related_notes_hash_migration_cache_identity(
389
+ export_file=export_file,
390
+ index_file=index_file,
391
+ wiki_dir=wiki_dir,
392
+ )
393
+ if identity is None:
394
+ return None
395
+ try:
396
+ payload = json.loads(_related_notes_hash_migration_cache_path().read_text(encoding="utf-8"))
397
+ except (OSError, json.JSONDecodeError):
398
+ return None
399
+ try:
400
+ cache = RelatedNotesHashMigrationCache.model_validate(payload)
401
+ except ValidationError:
402
+ return None
403
+ if cache.identity != identity:
404
+ return None
405
+ return {
406
+ "status": "skipped",
407
+ "skipped_reason": "cached_clean_v1_hash_migration",
408
+ "cache_status": "hit",
409
+ "cache_path": str(_related_notes_hash_migration_cache_path()),
410
+ "cached_status": cache.status,
411
+ "migrated_note_count": cache.migrated_note_count,
412
+ "skipped_note_count": cache.skipped_note_count,
413
+ }
414
+
415
+
416
+ def _write_related_notes_hash_migration_cache(
417
+ *,
418
+ export_file: Path,
419
+ index_file: Path,
420
+ wiki_dir: Path,
421
+ status: str,
422
+ migrated_note_count: int,
423
+ skipped_note_count: int,
424
+ ) -> None:
425
+ identity = _related_notes_hash_migration_cache_identity(
426
+ export_file=export_file,
427
+ index_file=index_file,
428
+ wiki_dir=wiki_dir,
429
+ )
430
+ if identity is None:
431
+ return
432
+ payload = {
433
+ "schema": RELATED_NOTES_HASH_MIGRATION_CACHE_SCHEMA,
434
+ "identity": identity,
435
+ "status": status,
436
+ "migrated_note_count": migrated_note_count,
437
+ "skipped_note_count": skipped_note_count,
438
+ }
439
+ try:
440
+ cache_path = _related_notes_hash_migration_cache_path()
441
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
442
+ atomic_write_text(cache_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
443
+ except OSError:
444
+ return
445
+
446
+
447
+ def migrate_related_notes_clean_v1_table_hashes(
448
+ wiki_dir: Path,
449
+ *,
450
+ export_path: Path | None = None,
451
+ index_path: Path | None = None,
452
+ now_ms: int | None = None,
453
+ ) -> dict[str, Any]:
454
+ """Migrate legacy clean_v1 hashes when only table padding normalization changed."""
455
+ wiki = wiki_dir.resolve(strict=False)
456
+ plugin_dir = wiki / ".obsidian" / "plugins" / PLUGIN_ID
457
+ export_file = export_path or plugin_dir / PLUGIN_EXPORT_NAME
458
+ index_file = index_path or plugin_dir / PLUGIN_INDEX_NAME
459
+ base = {
460
+ "schema": "medical-notes-workbench.related-notes-hash-migration.v1",
461
+ "phase": "related_notes_hash_migration",
462
+ "export_path": str(export_file),
463
+ "index_path": str(index_file),
464
+ "wiki_dir": str(wiki),
465
+ "embedding_api_calls": 0,
466
+ "migrated_note_count": 0,
467
+ }
468
+ if not export_file.is_file():
469
+ return {**base, "status": "skipped", "skipped_reason": "export_missing"}
470
+ if not index_file.is_file():
471
+ return {**base, "status": "skipped", "skipped_reason": "index_missing"}
472
+ cached = _related_notes_hash_migration_cache_hit(export_file=export_file, index_file=index_file, wiki_dir=wiki)
473
+ if cached is not None:
474
+ return {**base, **cached}
475
+ try:
476
+ export_payload = json.loads(export_file.read_text(encoding="utf-8"))
477
+ except (OSError, json.JSONDecodeError) as exc:
478
+ return {**base, "status": "skipped", "skipped_reason": "export_unreadable", "detail": _redact_error(str(exc))}
479
+ try:
480
+ export = RelatedNotesHashMigrationExport.model_validate(export_payload)
481
+ except ValidationError:
482
+ return {**base, "status": "skipped", "skipped_reason": "export_schema_unsupported"}
483
+ profile_id = _profile_id(export.model_info.embedding_profile_id)
484
+ if profile_id != "clean_v1":
485
+ return {**base, "status": "skipped", "skipped_reason": "embedding_profile_not_clean_v1"}
486
+ current_notes = {note.rel_path: note for note in _load_markdown_notes(wiki, profile_id)}
487
+ # The migration lens is intentionally minimal, but the on-disk export must
488
+ # keep the full plugin contract. Preserve the original JSON and update only
489
+ # note hashes validated through RelatedNotesExportNote.
490
+ export_payload_for_write = JsonObjectAdapter.validate_python(export_payload)
491
+ export_note_indexes: dict[str, int] = {}
492
+ raw_notes_value = export_payload_for_write["notes"] if "notes" in export_payload_for_write else []
493
+ raw_notes_for_write: list[JsonValue] = raw_notes_value if isinstance(raw_notes_value, list) else []
494
+ for index, raw_note in enumerate(raw_notes_for_write):
495
+ if not isinstance(raw_note, dict):
496
+ continue
497
+ typed_note = RelatedNotesExportNote.model_validate(raw_note)
498
+ export_note_indexes[typed_note.path] = index
499
+ migration_candidates: list[tuple[RelatedNotesExportNote, _MarkdownNote, str]] = []
500
+ migrated_count = 0
501
+ skipped_count = 0
502
+ for item in export.notes:
503
+ rel_path = item.path
504
+ note = current_notes[rel_path] if rel_path in current_notes else None
505
+ if note is None:
506
+ skipped_count += 1
507
+ continue
508
+ exported_hash = item.content_hash
509
+ current_hash = "sha256:" + note.representation_hash
510
+ if exported_hash.lower() == current_hash.lower():
511
+ continue
512
+ legacy_hash = related_notes_legacy_clean_v1_content_hash(
513
+ path=note.rel_path,
514
+ title=note.title,
515
+ markdown=note.markdown,
516
+ )
517
+ if exported_hash.lower() != legacy_hash.lower():
518
+ skipped_count += 1
519
+ continue
520
+ migration_candidates.append((item, note, current_hash))
521
+ if not migration_candidates:
522
+ _write_related_notes_hash_migration_cache(
523
+ export_file=export_file,
524
+ index_file=index_file,
525
+ wiki_dir=wiki,
526
+ status="no_legacy_clean_v1_hashes",
527
+ migrated_note_count=0,
528
+ skipped_note_count=skipped_count,
529
+ )
530
+ return {**base, "status": "skipped", "skipped_reason": "no_legacy_clean_v1_hashes", "skipped_note_count": skipped_count}
531
+ existing_index = _load_vector_index(index_file)
532
+ records = _current_records(existing_index, profile_id)
533
+ updated_paths: list[str] = []
534
+ timestamp = now_ms if now_ms is not None else _now_ms()
535
+ for _item, note, current_hash in migration_candidates:
536
+ record = records.get(note.rel_path)
537
+ vector = _migration_vector(record, note, profile_id)
538
+ if vector is None:
539
+ skipped_count += 1
540
+ continue
541
+ records[note.rel_path] = _record(note, vector, profile_id=profile_id, updated_at=timestamp)
542
+ export_note_index = export_note_indexes.get(note.rel_path)
543
+ if export_note_index is None:
544
+ skipped_count += 1
545
+ continue
546
+ raw_note = raw_notes_for_write[export_note_index]
547
+ note_payload = JsonObjectAdapter.validate_python(raw_note if isinstance(raw_note, dict) else {})
548
+ note_payload["content_hash"] = current_hash
549
+ raw_notes_for_write[export_note_index] = note_payload
550
+ migrated_count += 1
551
+ updated_paths.append(note.rel_path)
552
+ if not migrated_count:
553
+ _write_related_notes_hash_migration_cache(
554
+ export_file=export_file,
555
+ index_file=index_file,
556
+ wiki_dir=wiki,
557
+ status="legacy_clean_v1_vectors_missing",
558
+ migrated_note_count=0,
559
+ skipped_note_count=skipped_count,
560
+ )
561
+ return {
562
+ **base,
563
+ "status": "skipped",
564
+ "skipped_reason": "legacy_clean_v1_vectors_missing",
565
+ "skipped_note_count": skipped_count,
566
+ }
567
+ try:
568
+ vector_index = _vector_index(existing_index, records, profile_id=profile_id, updated_at=timestamp)
569
+ atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
570
+ atomic_write_text(export_file, json.dumps(export_payload_for_write, ensure_ascii=False, indent=2) + "\n")
571
+ except OSError as exc:
572
+ return {
573
+ **base,
574
+ "status": "blocked",
575
+ "blocked_reason": "related_notes_hash_migration_write_failed",
576
+ "next_action": "Verificar permissões do export/índice do Related Notes antes de aplicar correções na Wiki.",
577
+ "detail": _redact_error(str(exc)),
578
+ "migrated_note_count": migrated_count,
579
+ }
580
+ _write_related_notes_hash_migration_cache(
581
+ export_file=export_file,
582
+ index_file=index_file,
583
+ wiki_dir=wiki,
584
+ status="migrated_clean_v1_hashes",
585
+ migrated_note_count=migrated_count,
586
+ skipped_note_count=skipped_count,
587
+ )
588
+ return {
589
+ **base,
590
+ "status": "completed",
591
+ "migrated_note_count": migrated_count,
592
+ "skipped_note_count": skipped_count,
593
+ "updated_paths": updated_paths[:25],
594
+ }
595
+
596
+
597
+ def headless_plugin_settings_available(wiki_dir: Path) -> bool:
598
+ return (wiki_dir / ".obsidian" / "plugins" / PLUGIN_ID / PLUGIN_SETTINGS_NAME).is_file()
599
+
600
+
601
+ def normalize_related_notes_profile_id(value: Any) -> str:
602
+ return _profile_id(value)
603
+
604
+
605
+ def related_notes_representation_hash(
606
+ *,
607
+ path: str,
608
+ title: str,
609
+ markdown: str,
610
+ profile_id: str = DEFAULT_PROFILE_ID,
611
+ ) -> str:
612
+ representation = _build_representation(
613
+ path=path,
614
+ title=title,
615
+ markdown=markdown,
616
+ profile_id=normalize_related_notes_profile_id(profile_id),
617
+ )
618
+ return _sha256_text(representation)
619
+
620
+
621
+ def related_notes_content_hash(
622
+ *,
623
+ path: str,
624
+ title: str,
625
+ markdown: str,
626
+ profile_id: str = DEFAULT_PROFILE_ID,
627
+ ) -> str:
628
+ return "sha256:" + related_notes_representation_hash(
629
+ path=path,
630
+ title=title,
631
+ markdown=markdown,
632
+ profile_id=profile_id,
633
+ )
634
+
635
+
636
+ def _load_settings(settings_path: Path) -> dict[str, Any]:
637
+ if not settings_path.is_file():
638
+ raise HeadlessRelatedNotesExportError(
639
+ blocked_reason="related_notes_headless_plugin_settings_missing",
640
+ next_action="Instalar/configurar o plugin Related Notes neste vault e repetir a recuperação do export.",
641
+ detail="Related Notes plugin data.json was not found.",
642
+ )
643
+ try:
644
+ parsed = json.loads(settings_path.read_text(encoding="utf-8"))
645
+ except json.JSONDecodeError as exc:
646
+ raise HeadlessRelatedNotesExportError(
647
+ blocked_reason="related_notes_headless_plugin_settings_invalid",
648
+ next_action="Corrigir data.json do plugin Related Notes e repetir a recuperação do export.",
649
+ detail=str(exc),
650
+ ) from exc
651
+ return parsed if isinstance(parsed, dict) else {}
652
+
653
+
654
+ def _profile_id(value: Any) -> str:
655
+ text = str(value or DEFAULT_PROFILE_ID)
656
+ return text if text in {"clean_v1", "raw_v1", "legacy_v0"} else DEFAULT_PROFILE_ID
657
+
658
+
659
+ def _related_limit(value: Any) -> int:
660
+ if isinstance(value, int | float) and math.isfinite(value):
661
+ return max(1, min(50, int(value)))
662
+ return 10
663
+
664
+
665
+ def _delay_seconds(value: Any) -> float:
666
+ if isinstance(value, int | float) and math.isfinite(value):
667
+ return max(MIN_EMBEDDING_REQUEST_DELAY_SECONDS, float(int(value)) / 1000.0)
668
+ return MIN_EMBEDDING_REQUEST_DELAY_SECONDS
669
+
670
+
671
+ def _max_embedding_seconds(value: float | None) -> float:
672
+ if value is not None and math.isfinite(value):
673
+ return max(0.0, float(value))
674
+ raw = os.environ.get("MEDNOTES_RELATED_NOTES_HEADLESS_MAX_SECONDS", "").strip()
675
+ if raw:
676
+ try:
677
+ parsed = float(raw)
678
+ except ValueError:
679
+ return DEFAULT_MAX_EMBEDDING_SECONDS
680
+ if math.isfinite(parsed):
681
+ return max(0.0, parsed)
682
+ return DEFAULT_MAX_EMBEDDING_SECONDS
683
+
684
+
685
+ def _load_markdown_notes(wiki_dir: Path, profile_id: str) -> list[_MarkdownNote]:
686
+ notes: list[_MarkdownNote] = []
687
+ for index, path in enumerate(sorted(wiki_dir.rglob("*.md")), start=1):
688
+ cooperative_cpu_yield(index)
689
+ rel = path.relative_to(wiki_dir)
690
+ if any(part.startswith(".") for part in rel.parts):
691
+ continue
692
+ markdown = path.read_text(encoding="utf-8")
693
+ rel_path = rel.as_posix()
694
+ title = path.stem
695
+ representation = _build_representation(path=rel_path, title=title, markdown=markdown, profile_id=profile_id)
696
+ representation_hash = related_notes_representation_hash(
697
+ path=rel_path,
698
+ title=title,
699
+ markdown=markdown,
700
+ profile_id=profile_id,
701
+ )
702
+ notes.append(
703
+ _MarkdownNote(
704
+ rel_path=rel_path,
705
+ abs_path=path,
706
+ title=title,
707
+ markdown=markdown,
708
+ raw_hash=_sha256_text(markdown),
709
+ representation=representation,
710
+ representation_hash=representation_hash,
711
+ )
712
+ )
713
+ return notes
714
+
715
+
716
+ def _build_representation(*, path: str, title: str, markdown: str, profile_id: str) -> str:
717
+ body = _profile_body(markdown, profile_id)
718
+ truncated = body[:MAX_EMBEDDING_CHARS]
719
+ return f"Título: {title}\nCaminho: {path}\n\nConteúdo:\n{truncated}"
720
+
721
+
722
+ def _profile_body(markdown: str, profile_id: str) -> str:
723
+ if profile_id == "raw_v1":
724
+ return markdown
725
+ if profile_id == "legacy_v0":
726
+ return _clean_markdown_legacy(markdown)
727
+ return _clean_markdown_v1(markdown)
728
+
729
+
730
+ def _clean_markdown_legacy(markdown: str) -> str:
731
+ text = _FRONTMATTER_YAML_RE.sub("", markdown)
732
+ text = _CODE_BLOCK_RE.sub("[CODE BLOCK]", text)
733
+ text = _WIKILINK_ALIAS_RE.sub(r"\2", text)
734
+ text = _WIKILINK_RE.sub(r"\1", text)
735
+ text = _MARKDOWN_LINK_RE.sub(r"\1", text)
736
+ return re.sub(r"\s+", " ", text).strip()
737
+
738
+
739
+ def _clean_markdown_v1(markdown: str) -> str:
740
+ return _clean_markdown_v1_with_table_normalization(markdown)
741
+
742
+
743
+ def _clean_markdown_v1_legacy_table_spacing(markdown: str) -> str:
744
+ return _clean_markdown_v1_base(markdown, normalize_tables=False)
745
+
746
+
747
+ def _clean_markdown_v1_with_table_normalization(markdown: str) -> str:
748
+ return _clean_markdown_v1_base(markdown, normalize_tables=True)
749
+
750
+
751
+ def _clean_markdown_v1_base(markdown: str, *, normalize_tables: bool) -> str:
752
+ code_blocks: list[str] = []
753
+
754
+ def stash_code_block(match: re.Match[str]) -> str:
755
+ token = f"@@RELATED_NOTES_CODE_BLOCK_{len(code_blocks)}@@"
756
+ code_blocks.append(match.group(0))
757
+ return token
758
+
759
+ text = _CODE_BLOCK_RE.sub(stash_code_block, markdown)
760
+ text = _FRONTMATTER_YAML_RE.sub("", text)
761
+ text = _FRONTMATTER_TOML_RE.sub("", text)
762
+ text = _remove_related_notes_section(text)
763
+ text = _GENERATED_FOOTER_RE.sub("", text)
764
+ text = _COMMENT_RE.sub("", text)
765
+ text = _OBSIDIAN_IMAGE_RE.sub("", text)
766
+ text = _MARKDOWN_IMAGE_RE.sub("", text)
767
+ text = _WIKILINK_ALIAS_RE.sub(r"\2", text)
768
+ text = _WIKILINK_RE.sub(r"\1", text)
769
+ text = _MARKDOWN_LINK_RE.sub(r"\1", text)
770
+ if normalize_tables:
771
+ text = _normalize_markdown_table_padding(text)
772
+ text = _restore_code_blocks(text, code_blocks)
773
+ return _normalize_clean_whitespace(text)
774
+
775
+
776
+ def _normalize_markdown_table_padding(text: str) -> str:
777
+ return "\n".join(_normalize_markdown_table_row(line) for line in text.splitlines())
778
+
779
+
780
+ def _normalize_markdown_table_row(line: str) -> str:
781
+ stripped = line.strip()
782
+ if not stripped.startswith("|") or stripped.count("|") < 2:
783
+ return line
784
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
785
+ return "| " + " | ".join(_normalize_markdown_table_cell(cell) for cell in cells) + " |"
786
+
787
+
788
+ def _normalize_markdown_table_cell(cell: str) -> str:
789
+ compact = cell.replace(" ", "").replace("\t", "")
790
+ if re.fullmatch(r":?-{3,}:?", compact):
791
+ return f"{':' if compact.startswith(':') else ''}---{':' if compact.endswith(':') else ''}"
792
+ return cell
793
+
794
+
795
+ def _remove_related_notes_section(text: str) -> str:
796
+ match = _RELATED_HEADING_RE.search(text)
797
+ if not match:
798
+ return text
799
+ next_heading = _NEXT_H2_RE.search(text, match.end())
800
+ end = next_heading.start() if next_heading else len(text)
801
+ return f"{text[: match.start()]}{text[end:]}"
802
+
803
+
804
+ def _restore_code_blocks(text: str, code_blocks: list[str]) -> str:
805
+ def restore(match: re.Match[str]) -> str:
806
+ index = int(match.group(1))
807
+ return code_blocks[index] if 0 <= index < len(code_blocks) else ""
808
+
809
+ return re.sub(r"@@RELATED_NOTES_CODE_BLOCK_(\d+)@@", restore, text)
810
+
811
+
812
+ def _normalize_clean_whitespace(text: str) -> str:
813
+ return (
814
+ "\n".join(line.rstrip() for line in text.splitlines())
815
+ .replace("\n\n\n", "\n\n")
816
+ .strip()
817
+ )
818
+
819
+
820
+ def _load_vector_index(index_path: Path) -> dict[str, Any]:
821
+ if not index_path.is_file():
822
+ return {}
823
+ try:
824
+ parsed = json.loads(index_path.read_text(encoding="utf-8"))
825
+ except json.JSONDecodeError:
826
+ return {}
827
+ return parsed if isinstance(parsed, dict) else {}
828
+
829
+
830
+ def _current_records(index: dict[str, Any], profile_id: str) -> dict[str, RelatedNotesVectorRecord]:
831
+ vector_index = RelatedNotesVectorIndex.model_validate(index)
832
+ return vector_index.records_for_profile(profile_id)
833
+
834
+
835
+ def _record_is_current(record: RelatedNotesVectorRecord | None, note: _MarkdownNote, profile_id: str) -> bool:
836
+ if not record:
837
+ return False
838
+ return (
839
+ record.representation_hash == note.representation_hash
840
+ and record.embedding_model == DEFAULT_EMBEDDING_MODEL
841
+ and record.embedding_profile == profile_id
842
+ and record.embedding_profile_version == PROFILE_VERSION
843
+ and _valid_vector(record.vector)
844
+ )
845
+
846
+
847
+ def _record_is_legacy_clean_v1_current(
848
+ record: RelatedNotesVectorRecord | None,
849
+ note: _MarkdownNote,
850
+ profile_id: str,
851
+ ) -> bool:
852
+ if profile_id != "clean_v1" or not record or not _valid_vector(record.vector):
853
+ return False
854
+ return (
855
+ record.representation_hash == related_notes_legacy_clean_v1_representation_hash(
856
+ path=note.rel_path,
857
+ title=note.title,
858
+ markdown=note.markdown,
859
+ )
860
+ and record.embedding_model == DEFAULT_EMBEDDING_MODEL
861
+ and record.embedding_profile == profile_id
862
+ and record.embedding_profile_version == PROFILE_VERSION
863
+ )
864
+
865
+
866
+ def _migration_vector(record: RelatedNotesVectorRecord | None, note: _MarkdownNote, profile_id: str) -> list[float] | None:
867
+ if not record or not _valid_vector(record.vector):
868
+ return None
869
+ if not (_record_is_current(record, note, profile_id) or _record_is_legacy_clean_v1_current(record, note, profile_id)):
870
+ return None
871
+ return [float(item) for item in record.vector]
872
+
873
+
874
+ def related_notes_legacy_clean_v1_content_hash(*, path: str, title: str, markdown: str) -> str:
875
+ return "sha256:" + related_notes_legacy_clean_v1_representation_hash(path=path, title=title, markdown=markdown)
876
+
877
+
878
+ def related_notes_legacy_clean_v1_representation_hash(*, path: str, title: str, markdown: str) -> str:
879
+ representation = (
880
+ f"Título: {title}\n"
881
+ f"Caminho: {path}\n\n"
882
+ "Conteúdo:\n"
883
+ f"{_clean_markdown_v1_legacy_table_spacing(markdown)[:MAX_EMBEDDING_CHARS]}"
884
+ )
885
+ return _sha256_text(representation)
886
+
887
+
888
+ def _recovery_progress_counts(
889
+ *,
890
+ records: dict[str, RelatedNotesVectorRecord],
891
+ notes: list[_MarkdownNote],
892
+ reused_count: int,
893
+ embedded_count: int,
894
+ ) -> dict[str, int]:
895
+ total_note_count = len(notes)
896
+ fresh_record_count = min(total_note_count, max(0, reused_count) + max(0, embedded_count))
897
+ record_count = len(records)
898
+ return {
899
+ "record_count": record_count,
900
+ "fresh_record_count": fresh_record_count,
901
+ "stale_record_count": max(0, record_count - fresh_record_count),
902
+ "remaining_count": max(0, total_note_count - fresh_record_count),
903
+ }
904
+
905
+
906
+ def _record(note: _MarkdownNote, vector: list[float], *, profile_id: str, updated_at: int) -> RelatedNotesVectorRecord:
907
+ return RelatedNotesVectorRecord(
908
+ path=note.rel_path,
909
+ title=note.title,
910
+ folder=str(Path(note.rel_path).parent).replace(".", "") if "/" in note.rel_path else "",
911
+ preview=_make_preview(note.representation),
912
+ rawContentHash=note.raw_hash,
913
+ representationHash=note.representation_hash,
914
+ contentHash=note.representation_hash,
915
+ mtime=int(note.abs_path.stat().st_mtime * 1000),
916
+ embeddingModel=DEFAULT_EMBEDDING_MODEL,
917
+ embeddingProfile=profile_id,
918
+ embeddingProfileVersion=PROFILE_VERSION,
919
+ vector=[float(item) for item in vector],
920
+ updatedAt=updated_at,
921
+ )
922
+
923
+
924
+ def _make_preview(representation: str, limit: int = 200) -> str:
925
+ lines = representation.split("\n")
926
+ try:
927
+ content_index = next(index for index, line in enumerate(lines) if line.startswith("Conteúdo:"))
928
+ except StopIteration:
929
+ content_index = -1
930
+ content = " ".join(lines[content_index + 1 :])
931
+ return content[:limit] + ("..." if len(content) > limit else "")
932
+
933
+
934
+ def _vector_index(
935
+ existing_index: dict[str, Any],
936
+ records: dict[str, RelatedNotesVectorRecord],
937
+ *,
938
+ profile_id: str,
939
+ updated_at: int,
940
+ ) -> dict[str, Any]:
941
+ profiles = RelatedNotesVectorIndex.model_validate(existing_index).other_profiles_payload(profile_id)
942
+ profiles[profile_id] = {
943
+ "profileId": profile_id,
944
+ "profileVersion": PROFILE_VERSION,
945
+ "embeddingModel": DEFAULT_EMBEDDING_MODEL,
946
+ "updatedAt": updated_at,
947
+ "records": {key: value.to_payload() for key, value in sorted(records.items())},
948
+ }
949
+ return {
950
+ "schema": "related-notes-obsidian.vector-index.v2",
951
+ "updatedAt": updated_at,
952
+ "profiles": profiles,
953
+ }
954
+
955
+
956
+ def _export_payload(
957
+ notes: list[_MarkdownNote],
958
+ records: dict[str, RelatedNotesVectorRecord],
959
+ wiki_dir: Path,
960
+ *,
961
+ profile_id: str,
962
+ related_limit: int,
963
+ generated_at: str,
964
+ ) -> dict[str, Any]:
965
+ indexed_notes = [note for note in notes if note.rel_path in records]
966
+ note_paths = {note.rel_path for note in indexed_notes}
967
+ normalized_vectors = {
968
+ note.rel_path: normalized
969
+ for note in indexed_notes
970
+ if (normalized := _normalized_vector(records[note.rel_path].vector)) is not None
971
+ }
972
+ candidates_by_source: dict[str, list[tuple[float, str]]] = {note.rel_path: [] for note in indexed_notes}
973
+ for source_index, source in enumerate(indexed_notes):
974
+ source_vector = normalized_vectors.get(source.rel_path)
975
+ for target in indexed_notes[source_index + 1 :]:
976
+ target_vector = normalized_vectors.get(target.rel_path)
977
+ score = _cosine_normalized(source_vector, target_vector) if source_vector is not None else 0.0
978
+ if source_vector is not None:
979
+ candidates_by_source[source.rel_path].append((score, target.rel_path))
980
+ if target_vector is not None:
981
+ candidates_by_source[target.rel_path].append((score, source.rel_path))
982
+ edges: list[dict[str, Any]] = []
983
+ for note in indexed_notes:
984
+ for rank, (score, target_path) in enumerate(
985
+ sorted(candidates_by_source.get(note.rel_path, []), key=lambda item: (-item[0], item[1]))[:related_limit],
986
+ start=1,
987
+ ):
988
+ if target_path in note_paths:
989
+ edges.append(
990
+ {
991
+ "source_path": note.rel_path,
992
+ "target_path": target_path,
993
+ "score": _clamp_score(score),
994
+ "rank": rank,
995
+ "source": PLUGIN_ID,
996
+ }
997
+ )
998
+ return {
999
+ "schema": RELATED_NOTES_EXPORT_SCHEMA,
1000
+ "generated_at": generated_at,
1001
+ "vault_root": ".",
1002
+ "plugin": {"name": PLUGIN_ID, "version": _plugin_version(wiki_dir)},
1003
+ "model": {
1004
+ "embedding_model": DEFAULT_EMBEDDING_MODEL,
1005
+ "embedding_profile_id": profile_id,
1006
+ "embedding_profile_version": PROFILE_VERSION,
1007
+ "representation_hash_basis": _representation_hash_basis(profile_id),
1008
+ },
1009
+ "score_scale": "0_to_1",
1010
+ "notes": [
1011
+ {"path": note.rel_path, "title": note.title, "content_hash": "sha256:" + note.representation_hash}
1012
+ for note in sorted(indexed_notes, key=lambda item: item.rel_path)
1013
+ ],
1014
+ "edges": edges,
1015
+ }
1016
+
1017
+
1018
+ def _plugin_version(wiki_dir: Path) -> str:
1019
+ manifest = wiki_dir / ".obsidian" / "plugins" / PLUGIN_ID / "manifest.json"
1020
+ try:
1021
+ parsed = json.loads(manifest.read_text(encoding="utf-8"))
1022
+ except (OSError, json.JSONDecodeError):
1023
+ return "headless"
1024
+ return str(parsed.get("version") or "headless") if isinstance(parsed, dict) else "headless"
1025
+
1026
+
1027
+ def _representation_hash_basis(profile_id: str) -> str:
1028
+ if profile_id == "raw_v1":
1029
+ return "raw_markdown"
1030
+ if profile_id == "legacy_v0":
1031
+ return "legacy_hybrid_markdown"
1032
+ return "profile_cleaned_markdown"
1033
+
1034
+
1035
+ def _normalized_vector(value: Any) -> tuple[float, ...] | None:
1036
+ if not _valid_vector(value):
1037
+ return None
1038
+ floats = tuple(float(item) for item in value)
1039
+ norm = math.sqrt(sum(item * item for item in floats))
1040
+ if norm == 0:
1041
+ return None
1042
+ return tuple(item / norm for item in floats)
1043
+
1044
+
1045
+ def _cosine_normalized(left: tuple[float, ...], right: tuple[float, ...] | None) -> float:
1046
+ if right is None or len(left) != len(right):
1047
+ return 0.0
1048
+ sumprod = getattr(math, "sumprod", None)
1049
+ if callable(sumprod):
1050
+ typed_sumprod = cast(Callable[[Iterable[float], Iterable[float]], float], sumprod)
1051
+ return float(typed_sumprod(left, right))
1052
+ return sum(a * b for a, b in zip(left, right, strict=True))
1053
+
1054
+
1055
+ def _cosine(left: Any, right: Any) -> float:
1056
+ if not _valid_vector(left) or not _valid_vector(right) or len(left) != len(right):
1057
+ return 0.0
1058
+ dot = sum(float(a) * float(b) for a, b in zip(left, right, strict=True))
1059
+ left_norm = math.sqrt(sum(float(a) * float(a) for a in left))
1060
+ right_norm = math.sqrt(sum(float(b) * float(b) for b in right))
1061
+ if left_norm == 0 or right_norm == 0:
1062
+ return 0.0
1063
+ return dot / (left_norm * right_norm)
1064
+
1065
+
1066
+ def _valid_vector(value: Any) -> bool:
1067
+ return isinstance(value, list) and bool(value) and all(isinstance(item, int | float) for item in value)
1068
+
1069
+
1070
+ def _clamp_score(value: float) -> float:
1071
+ if not math.isfinite(value):
1072
+ return 0.0
1073
+ return max(0.0, min(1.0, value))
1074
+
1075
+
1076
+ def _chunks(items: list[_MarkdownNote], size: int) -> Iterable[list[_MarkdownNote]]:
1077
+ for index in range(0, len(items), size):
1078
+ yield items[index : index + size]
1079
+
1080
+
1081
+ def _default_embedding_client(texts: list[str], *, api_key: str, model: str) -> list[list[float]]:
1082
+ if len(texts) > 1:
1083
+ return _batch_embed(texts, api_key=api_key, model=model)
1084
+ return [_single_embed(text, api_key=api_key, model=model) for text in texts]
1085
+
1086
+
1087
+ def _batch_embed(texts: list[str], *, api_key: str, model: str) -> list[list[float]]:
1088
+ response = httpx.post(
1089
+ f"https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents",
1090
+ params={"key": api_key},
1091
+ json={
1092
+ "requests": [
1093
+ {
1094
+ "model": f"models/{model}",
1095
+ "content": {"parts": [{"text": text}]},
1096
+ }
1097
+ for text in texts
1098
+ ]
1099
+ },
1100
+ timeout=60.0,
1101
+ )
1102
+ if response.status_code in {400, 404}:
1103
+ raise BatchEmbeddingUnavailable("batch embedding endpoint unavailable")
1104
+ if response.status_code == 429:
1105
+ raise BatchEmbeddingUnavailable(_response_error_text(response), rate_limited=True)
1106
+ if response.status_code >= 400:
1107
+ raise HeadlessRelatedNotesExportError(
1108
+ blocked_reason="related_notes_headless_embedding_failed",
1109
+ next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
1110
+ detail=_redact_error(response.text),
1111
+ )
1112
+ try:
1113
+ payload = GeminiBatchEmbeddingResponse.model_validate(response.json())
1114
+ except ValidationError as exc:
1115
+ raise RuntimeError("batch embedding response missing embeddings[]") from exc
1116
+ return [_extract_embedding(item) for item in payload.embeddings]
1117
+
1118
+
1119
+ def _single_embed(text: str, *, api_key: str, model: str) -> list[float]:
1120
+ response = httpx.post(
1121
+ f"https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent",
1122
+ params={"key": api_key},
1123
+ json={"model": f"models/{model}", "content": {"parts": [{"text": text}]}},
1124
+ timeout=60.0,
1125
+ )
1126
+ if response.status_code >= 400:
1127
+ if response.status_code == 429:
1128
+ raise _quota_error(response)
1129
+ raise HeadlessRelatedNotesExportError(
1130
+ blocked_reason="related_notes_headless_embedding_failed",
1131
+ next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
1132
+ detail=_redact_error(response.text),
1133
+ )
1134
+ try:
1135
+ payload = GeminiEmbeddingResponse.model_validate(response.json())
1136
+ except ValidationError as exc:
1137
+ raise RuntimeError("embedding response missing values[]") from exc
1138
+ return _extract_embedding(payload.embedding)
1139
+
1140
+
1141
+ def _quota_error(response: httpx.Response) -> HeadlessRelatedNotesExportError:
1142
+ return HeadlessRelatedNotesExportError(
1143
+ blocked_reason="related_notes_headless_quota_exhausted",
1144
+ next_action=(
1145
+ "Aguardar a quota do Gemini embeddings voltar ou trocar a chave no plugin Related Notes; "
1146
+ "depois repetir a atualização das Notas Relacionadas pela rota oficial."
1147
+ ),
1148
+ detail=_redact_error(_response_error_text(response)),
1149
+ )
1150
+
1151
+
1152
+ def _extract_embedding(value: GeminiEmbedding) -> list[float]:
1153
+ return [float(item) for item in value.values]
1154
+
1155
+
1156
+ def _sha256_text(text: str) -> str:
1157
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
1158
+
1159
+
1160
+ def _now_iso() -> str:
1161
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
1162
+
1163
+
1164
+ def _now_ms() -> int:
1165
+ return int(time.time() * 1000)
1166
+
1167
+
1168
+ def _redact_error(message: str) -> str:
1169
+ return re.sub(r"key=[A-Za-z0-9_\-]+", "key=<redacted>", message)[:500]
1170
+
1171
+
1172
+ def _response_error_text(response: httpx.Response) -> str:
1173
+ try:
1174
+ payload = response.json()
1175
+ except ValueError:
1176
+ return response.text
1177
+ try:
1178
+ error_payload = GeminiErrorResponse.model_validate(payload)
1179
+ except ValidationError:
1180
+ return response.text
1181
+ if error_payload.error is not None:
1182
+ status = error_payload.error.status
1183
+ message = error_payload.error.message
1184
+ code = str(error_payload.error.code or response.status_code)
1185
+ return " ".join(part for part in [code, status, message] if part)
1186
+ return response.text