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,1920 @@
1
+ """Related Notes plugin export adapter for Wiki_Medicina."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Literal, Protocol
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, StrictBool, StrictStr
15
+ from pydantic import ValidationError as PydanticValidationError
16
+
17
+ from mednotes.domains.wiki.batch_state import canonical_json_hash, file_sha256
18
+ from mednotes.domains.wiki.capabilities.graph.graph import NO_STRONG_LINKS_MARKER
19
+ from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
20
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
21
+ from mednotes.domains.wiki.capabilities.related_notes.related_notes_headless import (
22
+ EmbeddingClient,
23
+ HeadlessRelatedNotesExportError,
24
+ generate_headless_related_notes_export,
25
+ headless_plugin_settings_available,
26
+ normalize_related_notes_profile_id,
27
+ related_notes_content_hash,
28
+ related_notes_legacy_clean_v1_content_hash,
29
+ )
30
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import is_index_note as _is_index_note
31
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import (
32
+ is_index_target,
33
+ normalize_key,
34
+ obsidian_target_name,
35
+ )
36
+ from mednotes.domains.wiki.common import _now_iso, wiki_cli_command
37
+ from mednotes.domains.wiki.config import MedConfig
38
+ from mednotes.domains.wiki.contracts.related_notes import RelatedNotesExport, RelatedNotesHeadlessExportSummary
39
+ from mednotes.domains.wiki.contracts.related_notes_runtime import LinkRelatedSyncResult
40
+ from mednotes.domains.wiki.flows.link.related_notes_fsm import (
41
+ build_related_notes_recovery_projection,
42
+ link_related_fsm_payload_from_sync_result,
43
+ )
44
+ from mednotes.domains.wiki.performance import cooperative_cpu_yield
45
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
46
+ from mednotes.platform.paths import user_state_dir
47
+
48
+ RELATED_NOTES_EXPORT_SCHEMA = "medical-notes-workbench.related-notes-export.v1"
49
+ RELATED_NOTES_SYNC_SCHEMA = "medical-notes-workbench.related-notes-sync.v1"
50
+ RELATED_NOTES_SYNC_RECEIPT_SCHEMA = "medical-notes-workbench.related-notes-sync-receipt.v1"
51
+ RELATED_NOTES_EXPORT_RECOVERY_SCHEMA = "medical-notes-workbench.related-notes-export-recovery.v1"
52
+ RELATED_NOTES_SAFETY_CLEANUP_SCHEMA = "medical-notes-workbench.related-notes-safety-cleanup.v1"
53
+ RELATED_NOTES_RESUMABLE_BLOCKERS = {
54
+ "related_notes_headless_quota_exhausted",
55
+ "related_notes_headless_time_budget_exhausted",
56
+ }
57
+ DEFAULT_RELATED_NOTES_EXPORT = ".obsidian/plugins/related-notes-obsidian/medical-notes-export.json"
58
+ DEFAULT_MIN_SCORE = 0.78
59
+ DEFAULT_MAX_LINKS = 10
60
+ RELATED_NOTES_REQUIRED_INPUTS = ["wiki_dir", "related_notes_export"]
61
+ RELATED_NOTES_PLUGIN_ID = "related-notes-obsidian"
62
+ RELATED_NOTES_COMMANDS = {
63
+ "reindex_vault": "related-notes-obsidian:reindex-vault",
64
+ "index_missing_notes": "related-notes-obsidian:index-missing-notes",
65
+ "export_only_diagnostic": "related-notes-obsidian:export-workbench-related-notes",
66
+ }
67
+ OBSIDIAN_PROBE_TIMEOUT_SECONDS = 30
68
+ OBSIDIAN_COMMAND_TIMEOUT_SECONDS = 120
69
+ OBSIDIAN_TIMEOUT_RETURNCODE = 124
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class _RelatedNotesExportCacheKey:
74
+ export_path: str
75
+ wiki_dir: str
76
+ mtime_ns: int
77
+ size: int
78
+ max_age_hours: float
79
+ allow_stale_note_hashes: bool
80
+
81
+
82
+ _RELATED_NOTES_EXPORT_CACHE: dict[_RelatedNotesExportCacheKey, _RelatedNotesExportValidation] = {}
83
+ _RELATED_NOTES_EXPORT_CACHE_MAX_ENTRIES = 8
84
+
85
+ _RELATED_HEADING_RE = re.compile(r"(?m)^##\s+(?:🔗\s+)?Notas Relacionadas\s*$")
86
+ _NEXT_H2_RE = re.compile(r"(?m)^##\s+")
87
+ _FOOTER_RE = re.compile(r"(?m)^---\s*$")
88
+ _WIKILINK_RE = re.compile(r"(?<!!)\[\[([^\]]+)\]\]")
89
+ _WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
90
+ _FORBIDDEN_EXPORT_KEYS = {
91
+ "apikey",
92
+ "geminiapikey",
93
+ "token",
94
+ "secret",
95
+ "password",
96
+ "content",
97
+ "markdown",
98
+ "rawmarkdown",
99
+ "body",
100
+ "vector",
101
+ "preview",
102
+ "embedding",
103
+ "embeddings",
104
+ }
105
+
106
+
107
+ class _ObsidianCommandRunner(Protocol):
108
+ """Boundary protocol for the Obsidian CLI runner used by recovery."""
109
+
110
+ def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
111
+ ...
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class RelatedNote:
116
+ rel_path: str
117
+ abs_path: Path
118
+ title: str
119
+ content_hash: str
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class RelatedEdge:
124
+ source_path: str
125
+ target_path: str
126
+ score: float
127
+ rank: int
128
+ source: str
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class _RelatedNotesParseBlocked:
133
+ """Typed parse failure used before a sync operation can plan mutations."""
134
+
135
+ blocked_reason: str
136
+ next_action: str
137
+ validation_errors: list[JsonObject]
138
+ stale_notes: list[JsonObject]
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class _RelatedNotesParsedNotes:
143
+ """Validated note map from the Related Notes export."""
144
+
145
+ notes: dict[str, RelatedNote]
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class _RelatedNotesParsedEdges:
150
+ """Validated graph edge list from the Related Notes export."""
151
+
152
+ edges: list[RelatedEdge]
153
+
154
+
155
+ _RelatedNotesNotesParseResult = _RelatedNotesParsedNotes | _RelatedNotesParseBlocked
156
+ _RelatedNotesEdgesParseResult = _RelatedNotesParsedEdges | _RelatedNotesParseBlocked
157
+
158
+
159
+ class _RelatedNotesBlockedParseInput(ContractModel):
160
+ """Boundary model for tests/tooling that still pass raw parse failures."""
161
+
162
+ blocked_reason: StrictStr
163
+ next_action: StrictStr
164
+ validation_errors: list[JsonObject] = Field(default_factory=list)
165
+ stale_notes: list[JsonObject] = Field(default_factory=list)
166
+
167
+ def to_result(self) -> _RelatedNotesParseBlocked:
168
+ return _RelatedNotesParseBlocked(
169
+ blocked_reason=self.blocked_reason,
170
+ next_action=self.next_action,
171
+ validation_errors=self.validation_errors,
172
+ stale_notes=self.stale_notes,
173
+ )
174
+
175
+
176
+ class _RelatedNotesExportValidation(BaseModel):
177
+ """Internal typed result for export preflight.
178
+
179
+ The external export is JSON, but the workflow must not branch on loose
180
+ dictionaries. This model is the boundary: callers read attributes and only
181
+ serialize back to payload at adapter edges.
182
+ """
183
+
184
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
185
+
186
+ status: Literal["ready", "blocked"]
187
+ export_path: Path
188
+ wiki_dir: Path
189
+ payload: JsonObject = Field(default_factory=dict)
190
+ notes: dict[str, RelatedNote] = Field(default_factory=dict)
191
+ edges: list[RelatedEdge] = Field(default_factory=list)
192
+ blocked_reason: StrictStr = ""
193
+ next_action: StrictStr = ""
194
+ hash_errors: list[JsonObject] = Field(default_factory=list)
195
+ stale_notes: list[JsonObject] = Field(default_factory=list)
196
+ validation_errors: list[JsonObject] = Field(default_factory=list)
197
+ hash_warnings: list[JsonObject] = Field(default_factory=list)
198
+ export_relocation: JsonObject = Field(default_factory=dict)
199
+ extra_payload: JsonObject = Field(default_factory=dict)
200
+
201
+ @property
202
+ def is_blocked(self) -> bool:
203
+ return self.status == "blocked"
204
+
205
+ def blocked_payload(self) -> JsonObject:
206
+ """Serialize a blocked preflight result using the public sync shape."""
207
+
208
+ extra: dict[str, object] = dict(self.extra_payload)
209
+ if self.hash_errors:
210
+ extra["hash_errors"] = self.hash_errors
211
+ if self.stale_notes:
212
+ extra["stale_notes"] = self.stale_notes
213
+ if self.validation_errors:
214
+ extra["validation_errors"] = self.validation_errors
215
+ if self.hash_warnings:
216
+ extra["hash_warnings"] = self.hash_warnings
217
+ if self.export_relocation:
218
+ extra["export_relocation"] = self.export_relocation
219
+ return _base_payload(
220
+ self.export_path,
221
+ self.wiki_dir,
222
+ status="blocked",
223
+ phase="related_notes_preflight",
224
+ blocked_reason=self.blocked_reason,
225
+ next_action=self.next_action,
226
+ extra=JsonObjectAdapter.validate_python(extra),
227
+ )
228
+
229
+
230
+ class _RelatedNotesProposedLink(ContractModel):
231
+ """One generated Related Notes wikilink candidate."""
232
+
233
+ target_path: StrictStr
234
+ target_title: StrictStr
235
+ score: float = Field(ge=0)
236
+ rank: int = Field(ge=0)
237
+ source: StrictStr
238
+ content_hash: StrictStr
239
+ line: StrictStr
240
+
241
+
242
+ class _RelatedNotesSkippedEdge(ContractModel):
243
+ """One graph edge intentionally excluded from the rendered section."""
244
+
245
+ source_path: StrictStr
246
+ target_path: StrictStr = ""
247
+ reason: StrictStr
248
+ score: StrictStr = ""
249
+
250
+
251
+ class _RelatedNotesPlannedUpdate(ContractModel):
252
+ """Private mutation plan for one note; `new_content` never enters preview."""
253
+
254
+ file: StrictStr
255
+ relative_path: StrictStr
256
+ source_title: StrictStr
257
+ content_hash: StrictStr
258
+ cleared_links: list[StrictStr] = Field(default_factory=list)
259
+ cleared_link_count: int = Field(default=0, ge=0)
260
+ proposed_links: list[_RelatedNotesProposedLink] = Field(default_factory=list)
261
+ new_content: StrictStr
262
+ changed: bool = Field(strict=True)
263
+ min_score: float = Field(ge=0)
264
+
265
+ def public_update(self) -> _RelatedNotesPublicUpdate:
266
+ return _RelatedNotesPublicUpdate.model_validate(
267
+ {
268
+ "file": self.file,
269
+ "relative_path": self.relative_path,
270
+ "source_title": self.source_title,
271
+ "content_hash": self.content_hash,
272
+ "cleared_links": self.cleared_links,
273
+ "cleared_link_count": self.cleared_link_count,
274
+ "proposed_links": [link.to_payload() for link in self.proposed_links],
275
+ "changed": self.changed,
276
+ "min_score": self.min_score,
277
+ }
278
+ )
279
+
280
+
281
+ class _RelatedNotesPublicUpdate(ContractModel):
282
+ """Preview/apply receipt shape for a Related Notes section update."""
283
+
284
+ file: StrictStr
285
+ relative_path: StrictStr
286
+ source_title: StrictStr
287
+ content_hash: StrictStr
288
+ cleared_links: list[StrictStr] = Field(default_factory=list)
289
+ cleared_link_count: int = Field(default=0, ge=0)
290
+ proposed_links: list[JsonObject] = Field(default_factory=list)
291
+ changed: bool = Field(strict=True)
292
+ min_score: float = Field(ge=0)
293
+ backup_path: StrictStr = ""
294
+ applied: bool = Field(default=False, strict=True)
295
+
296
+
297
+ class _RelatedNotesUpdatePlan(BaseModel):
298
+ """Typed sync plan; private content stays private until the apply adapter."""
299
+
300
+ model_config = ConfigDict(extra="forbid")
301
+
302
+ status: Literal["preview_ready"] = "preview_ready"
303
+ wiki_dir: StrictStr
304
+ updates: list[_RelatedNotesPublicUpdate] = Field(default_factory=list)
305
+ private_updates: list[_RelatedNotesPlannedUpdate] = Field(default_factory=list)
306
+ skipped_edges: list[_RelatedNotesSkippedEdge] = Field(default_factory=list)
307
+
308
+ def summary(self) -> dict[str, int]:
309
+ return {
310
+ "planned_note_count": len(self.updates),
311
+ "proposed_link_count": sum(len(update.proposed_links) for update in self.updates),
312
+ "cleared_link_count": sum(update.cleared_link_count for update in self.updates),
313
+ "skipped_edge_count": len(self.skipped_edges),
314
+ "applied_note_count": 0,
315
+ }
316
+
317
+ def public_updates_payload(self) -> list[JsonObject]:
318
+ return [update.to_payload() for update in self.updates]
319
+
320
+ def skipped_edges_payload(self) -> list[JsonObject]:
321
+ return [edge.to_payload() for edge in self.skipped_edges]
322
+
323
+
324
+ class _RelatedNotesSyncReceiptPayload(ContractModel):
325
+ schema_: StrictStr = Field(alias="schema", serialization_alias="schema")
326
+ generated_at: StrictStr
327
+ status: StrictStr
328
+ phase: StrictStr
329
+ dry_run: StrictBool
330
+ no_resource_mutation: StrictBool
331
+ wiki_dir: StrictStr
332
+ export_path: StrictStr
333
+ export_hash: StrictStr
334
+ export_generated_at: StrictStr = ""
335
+ plugin: JsonObject = Field(default_factory=dict)
336
+ model: JsonObject = Field(default_factory=dict)
337
+ api_calls: NonNegativeInt = 0
338
+ api_failures: NonNegativeInt = 0
339
+ plan_hash: StrictStr
340
+ applied_note_count: NonNegativeInt = 0
341
+ update_count: NonNegativeInt = 0
342
+ updates: list[JsonObject] = Field(default_factory=list)
343
+
344
+
345
+ class _RelatedNotesAppliedUpdatesPayload(ContractModel):
346
+ updates: list[JsonObject] = Field(default_factory=list)
347
+
348
+
349
+ def default_export_path(wiki_dir: Path) -> Path:
350
+ return wiki_dir / DEFAULT_RELATED_NOTES_EXPORT
351
+
352
+
353
+ def _link_related_run_id(result: LinkRelatedSyncResult) -> str:
354
+ basis = result.export_path or result.receipt_path or result.blocked_reason or "run"
355
+ safe = re.sub(r"[^A-Za-z0-9_.:-]+", "-", basis)[:48].strip("-")
356
+ return f"link-related-{safe or 'run'}"
357
+
358
+
359
+ def _link_related_version_control_safety(result: LinkRelatedSyncResult, *, applying: bool) -> dict[str, object]:
360
+ changed_update_count = sum(1 for update in result.updates if update.changed)
361
+ mutated = applying and (result.applied_note_count > 0 or changed_update_count > 0)
362
+ return {
363
+ "resource_guard_active": mutated,
364
+ "run_start_seen": mutated,
365
+ "run_finish_seen": mutated,
366
+ "restore_point_before": "vault-guard" if mutated else "",
367
+ "restore_point_after": "vault-guard" if mutated else "",
368
+ "sync_status": "not_checked",
369
+ "backup_online": "not_checked",
370
+ "direct_mutation_forbidden": True,
371
+ "mutation_without_guard": False,
372
+ "rollback_declared": mutated,
373
+ "no_resource_mutation": not mutated,
374
+ "changed_file_count": changed_update_count,
375
+ }
376
+
377
+
378
+ def _json_str_field(payload: JsonObject, key: str, default: str = "") -> str:
379
+ """Read a public JSON string after validating the object boundary."""
380
+
381
+ if key not in payload:
382
+ return default
383
+ value = payload[key]
384
+ if value is None:
385
+ return default
386
+ return str(value)
387
+
388
+
389
+ def recover_related_notes_export(
390
+ config: MedConfig,
391
+ *,
392
+ export_path: Path | None = None,
393
+ mode: str = "auto",
394
+ command_runner: _ObsidianCommandRunner | None = None,
395
+ headless_embedding_client: EmbeddingClient | None = None,
396
+ headless_now_iso: str | None = None,
397
+ headless_now_ms: int | None = None,
398
+ workflow: str = "/mednotes:link-related",
399
+ run_id: str = "related-notes-recovery",
400
+ ) -> JsonObject:
401
+ result = recover_related_notes_export_operation_result(
402
+ config,
403
+ export_path=export_path,
404
+ mode=mode,
405
+ command_runner=command_runner,
406
+ headless_embedding_client=headless_embedding_client,
407
+ headless_now_iso=headless_now_iso,
408
+ headless_now_ms=headless_now_ms,
409
+ workflow=workflow,
410
+ run_id=run_id,
411
+ )
412
+ sync_result = LinkRelatedSyncResult.from_payload(result)
413
+ return link_related_fsm_payload_from_sync_result(
414
+ JsonObjectAdapter.validate_python(result),
415
+ run_id=_link_related_run_id(sync_result),
416
+ mode="recover_export",
417
+ version_control_safety=_link_related_version_control_safety(sync_result, applying=False),
418
+ )
419
+
420
+
421
+ def recover_related_notes_export_operation_result(
422
+ config: MedConfig,
423
+ *,
424
+ export_path: Path | None = None,
425
+ mode: str = "auto",
426
+ command_runner: _ObsidianCommandRunner | None = None,
427
+ headless_embedding_client: EmbeddingClient | None = None,
428
+ headless_now_iso: str | None = None,
429
+ headless_now_ms: int | None = None,
430
+ workflow: str = "/mednotes:link-related",
431
+ run_id: str = "related-notes-recovery",
432
+ ) -> JsonObject:
433
+ export = export_path or default_export_path(config.wiki_dir)
434
+ preflight = _load_and_validate_export(export, config.wiki_dir, max_age_hours=168)
435
+ stale_notes = [
436
+ {
437
+ "path": str(item["path"]),
438
+ "expected_hash": str(item["expected"]),
439
+ "actual_hash": str(item["actual"]),
440
+ }
441
+ for item in preflight.hash_errors
442
+ ]
443
+ for item in preflight.stale_notes:
444
+ stale_notes.append(
445
+ {
446
+ "path": str(item["path"]),
447
+ "expected_hash": str(item["expected_hash"]),
448
+ "actual_hash": str(item["actual_hash"]),
449
+ }
450
+ )
451
+ if not preflight.is_blocked:
452
+ return _recovery_payload(
453
+ export,
454
+ config.wiki_dir,
455
+ status="recovered",
456
+ blocked_reason="",
457
+ recovery_mode="not_needed",
458
+ stale_notes=[],
459
+ next_action="",
460
+ extra={"retry_command": wiki_cli_command("related-notes-sync", "--dry-run", "--json")},
461
+ )
462
+ blocked_reason = preflight.blocked_reason
463
+ if blocked_reason not in {
464
+ "related_notes_hash_mismatch",
465
+ "related_notes_export_stale",
466
+ "related_notes_vault_mismatch",
467
+ }:
468
+ return _recovery_payload(
469
+ export,
470
+ config.wiki_dir,
471
+ status="blocked",
472
+ blocked_reason=blocked_reason or "related_notes_recovery_not_applicable",
473
+ recovery_mode="manual_required",
474
+ stale_notes=stale_notes,
475
+ next_action=preflight.next_action,
476
+ )
477
+
478
+ recovery_mode = _select_recovery_mode(mode=mode, stale_notes=stale_notes, blocked_reason=blocked_reason)
479
+ if recovery_mode == "export_only_diagnostic" and stale_notes:
480
+ return _recovery_payload(
481
+ export,
482
+ config.wiki_dir,
483
+ status="blocked",
484
+ blocked_reason="related_notes_export_only_unsafe_for_changed_notes",
485
+ recovery_mode=recovery_mode,
486
+ stale_notes=stale_notes,
487
+ next_action="Usar --mode reindex-vault para notas existentes editadas; export-only não corrige índice stale.",
488
+ )
489
+ runner = command_runner or _run_obsidian_command
490
+ if command_runner is None and shutil.which("obsidian") is None:
491
+ if headless_plugin_settings_available(config.wiki_dir):
492
+ return _recover_related_notes_export_headless(
493
+ config,
494
+ export,
495
+ recovery_mode=recovery_mode,
496
+ stale_notes=stale_notes,
497
+ embedding_client=headless_embedding_client,
498
+ now_iso=headless_now_iso,
499
+ now_ms=headless_now_ms,
500
+ workflow=workflow,
501
+ run_id=run_id,
502
+ )
503
+ return _recovery_payload(
504
+ export,
505
+ config.wiki_dir,
506
+ status="blocked",
507
+ blocked_reason=blocked_reason,
508
+ recovery_mode=recovery_mode,
509
+ stale_notes=stale_notes,
510
+ next_action=preflight.next_action or "Conferir o export do Related Notes pela rota oficial.",
511
+ extra={
512
+ "export_relocation": preflight.export_relocation,
513
+ "obsidian_cli_available": False,
514
+ "obsidian_running": False,
515
+ "automatic_recovery_unavailable_reason": "obsidian_cli_unavailable",
516
+ },
517
+ )
518
+
519
+ ready = runner(["obsidian", "help"])
520
+ if int(getattr(ready, "returncode", 1) or 0) != 0:
521
+ timed_out = int(getattr(ready, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE
522
+ extra = {
523
+ "command_discovery_status": "timeout" if timed_out else "not_ready",
524
+ "command_returncode": int(getattr(ready, "returncode", 1) or 0),
525
+ }
526
+ if timed_out:
527
+ extra["command_timeout_seconds"] = OBSIDIAN_PROBE_TIMEOUT_SECONDS
528
+ return _recovery_payload(
529
+ export,
530
+ config.wiki_dir,
531
+ status="blocked",
532
+ blocked_reason="obsidian_cli_timeout" if timed_out else "obsidian_not_ready",
533
+ recovery_mode=recovery_mode,
534
+ stale_notes=stale_notes,
535
+ next_action=(
536
+ "Obsidian CLI demorou demais para responder; verifique se o Obsidian está aberto e repita o recovery."
537
+ if timed_out
538
+ else "Abrir o Obsidian no vault configurado e repetir related-notes-sync --recover-export --mode auto --json."
539
+ ),
540
+ extra=extra,
541
+ )
542
+
543
+ discovered, discovery_status = _discover_related_notes_commands(runner)
544
+ if discovery_status == "timeout":
545
+ return _recovery_payload(
546
+ export,
547
+ config.wiki_dir,
548
+ status="blocked",
549
+ blocked_reason="obsidian_cli_timeout",
550
+ recovery_mode=recovery_mode,
551
+ stale_notes=stale_notes,
552
+ next_action="Obsidian CLI demorou demais para listar comandos; verifique o Obsidian e repita o recovery.",
553
+ extra={"command_discovery_status": discovery_status, "command_timeout_seconds": OBSIDIAN_PROBE_TIMEOUT_SECONDS},
554
+ )
555
+ command_id = RELATED_NOTES_COMMANDS[recovery_mode] if recovery_mode in RELATED_NOTES_COMMANDS else RELATED_NOTES_COMMANDS["reindex_vault"]
556
+ if discovery_status == "discovered" and command_id not in discovered:
557
+ blocked_reason = (
558
+ "related_notes_plugin_unavailable"
559
+ if not discovered
560
+ else "related_notes_export_command_missing"
561
+ if recovery_mode == "export_only_diagnostic"
562
+ else "related_notes_reindex_command_missing"
563
+ )
564
+ return _recovery_payload(
565
+ export,
566
+ config.wiki_dir,
567
+ status="blocked",
568
+ blocked_reason=blocked_reason,
569
+ recovery_mode=recovery_mode,
570
+ stale_notes=stale_notes,
571
+ next_action="Habilitar related-notes-obsidian no vault e repetir o recovery.",
572
+ extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status},
573
+ )
574
+
575
+ command = ["obsidian", f"vault={config.wiki_dir.name}", "command", f"id={command_id}"]
576
+ result = runner(command)
577
+ if int(getattr(result, "returncode", 1) or 0) != 0:
578
+ timed_out = int(getattr(result, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE
579
+ extra = {
580
+ "discovered_commands": sorted(discovered),
581
+ "command_discovery_status": discovery_status,
582
+ "command_returncode": int(getattr(result, "returncode", 1) or 0),
583
+ }
584
+ if timed_out:
585
+ extra["command_timeout_seconds"] = OBSIDIAN_COMMAND_TIMEOUT_SECONDS
586
+ return _recovery_payload(
587
+ export,
588
+ config.wiki_dir,
589
+ status="blocked",
590
+ blocked_reason="obsidian_cli_timeout" if timed_out else "related_notes_reindex_failed",
591
+ recovery_mode=recovery_mode,
592
+ stale_notes=stale_notes,
593
+ next_action=(
594
+ "Obsidian CLI demorou demais para executar o comando do plugin; verifique o Obsidian e repita o recovery."
595
+ if timed_out
596
+ else "Corrigir erro do plugin/Obsidian CLI e repetir related-notes-sync --recover-export."
597
+ ),
598
+ extra=extra,
599
+ )
600
+
601
+ validation = sync_related_notes_operation_result(config, export_path=export, apply=False)
602
+ validation_result = LinkRelatedSyncResult.from_payload(validation)
603
+ if validation_result.status == "blocked" or validation_result.blocked_reason:
604
+ return _recovery_payload(
605
+ export,
606
+ config.wiki_dir,
607
+ status="blocked",
608
+ blocked_reason="related_notes_export_still_stale"
609
+ if validation_result.blocked_reason == "related_notes_hash_mismatch"
610
+ else validation_result.blocked_reason or "related_notes_revalidation_failed",
611
+ recovery_mode=recovery_mode,
612
+ stale_notes=stale_notes,
613
+ next_action=validation_result.next_action or "Revalidar export do Related Notes antes do apply.",
614
+ extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status, "revalidation": validation},
615
+ )
616
+ return _recovery_payload(
617
+ export,
618
+ config.wiki_dir,
619
+ status="recovered",
620
+ blocked_reason="",
621
+ recovery_mode=recovery_mode,
622
+ stale_notes=stale_notes,
623
+ next_action=wiki_cli_command("related-notes-sync", "--dry-run", "--json"),
624
+ extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status, "revalidation": validation},
625
+ )
626
+
627
+
628
+ def _recover_related_notes_export_headless(
629
+ config: MedConfig,
630
+ export: Path,
631
+ *,
632
+ recovery_mode: str,
633
+ stale_notes: list[dict[str, str]],
634
+ embedding_client: EmbeddingClient | None,
635
+ now_iso: str | None,
636
+ now_ms: int | None,
637
+ workflow: str,
638
+ run_id: str,
639
+ ) -> JsonObject:
640
+ try:
641
+ headless = generate_headless_related_notes_export(
642
+ config.wiki_dir,
643
+ export_path=export,
644
+ embedding_client=embedding_client,
645
+ now_iso=now_iso,
646
+ now_ms=now_ms,
647
+ )
648
+ except HeadlessRelatedNotesExportError as exc:
649
+ next_action = exc.next_action
650
+ if exc.partial_record_count:
651
+ label = "registro" if exc.partial_record_count == 1 else "registros"
652
+ next_action = (
653
+ f"{exc.next_action} O índice parcial já tem {exc.partial_record_count} {label}; "
654
+ "a próxima tentativa retoma desse ponto."
655
+ )
656
+ recovery_state = {
657
+ "schema": "medical-notes-workbench.related-notes-recovery-state.v1",
658
+ "status": "waiting_for_retry" if exc.blocked_reason in RELATED_NOTES_RESUMABLE_BLOCKERS else "blocked",
659
+ "blocked_reason": exc.blocked_reason,
660
+ "resume_supported": bool(exc.partial_record_count),
661
+ "partial_record_count": exc.partial_record_count,
662
+ "fresh_record_count": exc.fresh_record_count or exc.partial_record_count,
663
+ "stale_record_count": exc.stale_record_count,
664
+ "record_count": exc.record_count,
665
+ "total_note_count": exc.total_note_count,
666
+ "remaining_count": exc.remaining_count,
667
+ "embedded_count": exc.embedded_count,
668
+ "reused_count": exc.reused_count,
669
+ "next_retry_after_seconds": exc.next_retry_after_seconds,
670
+ "attempt_count": 1,
671
+ }
672
+ projection = build_related_notes_recovery_projection(
673
+ workflow=workflow,
674
+ run_id=run_id,
675
+ recovery_state=recovery_state,
676
+ next_action=next_action,
677
+ )
678
+ return _recovery_payload(
679
+ export,
680
+ config.wiki_dir,
681
+ status="blocked",
682
+ blocked_reason=exc.blocked_reason,
683
+ recovery_mode="headless_reindex_vault",
684
+ stale_notes=stale_notes,
685
+ next_action=next_action,
686
+ extra={
687
+ "headless_export": {
688
+ "status": "blocked",
689
+ "phase": "related_notes_headless_export",
690
+ "blocked_reason": exc.blocked_reason,
691
+ "detail": exc.detail,
692
+ "partial_record_count": exc.partial_record_count,
693
+ "fresh_record_count": exc.fresh_record_count or exc.partial_record_count,
694
+ "stale_record_count": exc.stale_record_count,
695
+ "record_count": exc.record_count,
696
+ "total_note_count": exc.total_note_count,
697
+ "remaining_count": exc.remaining_count,
698
+ "embedded_count": exc.embedded_count,
699
+ "reused_count": exc.reused_count,
700
+ "next_retry_after_seconds": exc.next_retry_after_seconds,
701
+ "resume_supported": bool(exc.partial_record_count),
702
+ },
703
+ "related_notes_recovery_state": recovery_state,
704
+ "progress_state": projection.progress_state.to_payload(),
705
+ "progress_view_model": projection.progress_view_model.to_payload(),
706
+ "state_machine_snapshot": projection.snapshot.to_payload(),
707
+ "obsidian_cli_available": False,
708
+ "obsidian_running": False,
709
+ "fallback_from_recovery_mode": recovery_mode,
710
+ },
711
+ )
712
+ validation = sync_related_notes_operation_result(config, export_path=export, apply=False)
713
+ validation_result = LinkRelatedSyncResult.from_payload(validation)
714
+ if validation_result.status == "blocked" or validation_result.blocked_reason:
715
+ return _recovery_payload(
716
+ export,
717
+ config.wiki_dir,
718
+ status="blocked",
719
+ blocked_reason="related_notes_export_still_stale"
720
+ if validation_result.blocked_reason == "related_notes_hash_mismatch"
721
+ else validation_result.blocked_reason or "related_notes_revalidation_failed",
722
+ recovery_mode="headless_reindex_vault",
723
+ stale_notes=stale_notes,
724
+ next_action=validation_result.next_action or "Revalidar export do Related Notes antes do apply.",
725
+ extra={
726
+ "headless_export": headless,
727
+ "obsidian_cli_available": False,
728
+ "obsidian_running": False,
729
+ "fallback_from_recovery_mode": recovery_mode,
730
+ "revalidation": validation,
731
+ },
732
+ )
733
+ return _recovery_payload(
734
+ export,
735
+ config.wiki_dir,
736
+ status="recovered",
737
+ blocked_reason="",
738
+ recovery_mode="headless_reindex_vault",
739
+ stale_notes=stale_notes,
740
+ next_action=wiki_cli_command("related-notes-sync", "--dry-run", "--json"),
741
+ extra={
742
+ "headless_export": headless,
743
+ "obsidian_cli_available": False,
744
+ "obsidian_running": False,
745
+ "fallback_from_recovery_mode": recovery_mode,
746
+ "revalidation": validation,
747
+ },
748
+ )
749
+
750
+
751
+ def _select_recovery_mode(*, mode: str, stale_notes: list[dict[str, str]], blocked_reason: str = "") -> str:
752
+ normalized = mode.replace("-", "_")
753
+ if normalized == "auto":
754
+ return (
755
+ "reindex_vault"
756
+ if stale_notes or blocked_reason in {"related_notes_export_stale", "related_notes_vault_mismatch"}
757
+ else "manual_required"
758
+ )
759
+ if normalized in {"reindex_vault", "index_missing", "index_missing_notes", "export_only_diagnostic"}:
760
+ return "index_missing_notes" if normalized == "index_missing" else normalized
761
+ return "manual_required"
762
+
763
+
764
+ def _public_recovery_mode(recovery_mode: str, *, blocked_reason: str = "") -> str:
765
+ mapping = {
766
+ "headless_reindex_vault": "headless-reindex-vault",
767
+ "reindex_vault": "reindex-vault",
768
+ "index_missing_notes": "index-missing",
769
+ "export_only_diagnostic": "export-only-diagnostic",
770
+ "manual_required": "manual",
771
+ "not_needed": "manual",
772
+ }
773
+ if not recovery_mode and blocked_reason == "related_notes_hash_mismatch":
774
+ return "reindex-vault"
775
+ return mapping[recovery_mode] if recovery_mode in mapping else "manual"
776
+
777
+
778
+ def _manual_instruction_allowed(blocked_reason: str) -> bool:
779
+ return blocked_reason in {
780
+ "obsidian_cli_unavailable",
781
+ "obsidian_not_ready",
782
+ "obsidian_cli_timeout",
783
+ "related_notes_plugin_unavailable",
784
+ "related_notes_export_command_missing",
785
+ "related_notes_headless_quota_exhausted",
786
+ "related_notes_reindex_command_missing",
787
+ "plugin_command_unavailable",
788
+ }
789
+
790
+
791
+ def _run_obsidian_command(argv: list[str]) -> subprocess.CompletedProcess[str]:
792
+ timeout = (
793
+ OBSIDIAN_PROBE_TIMEOUT_SECONDS
794
+ if tuple(argv[:2]) in {("obsidian", "help"), ("obsidian", "commands")}
795
+ else OBSIDIAN_COMMAND_TIMEOUT_SECONDS
796
+ )
797
+ command = list(argv)
798
+ if command[:1] == ["obsidian"]:
799
+ command[0] = shutil.which("obsidian") or command[0]
800
+ try:
801
+ return subprocess.run(command, text=True, capture_output=True, check=False, timeout=timeout)
802
+ except subprocess.TimeoutExpired as exc:
803
+ stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
804
+ stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
805
+ return subprocess.CompletedProcess(
806
+ command,
807
+ OBSIDIAN_TIMEOUT_RETURNCODE,
808
+ stdout=stdout,
809
+ stderr=stderr or f"Obsidian CLI timed out after {timeout} seconds",
810
+ )
811
+
812
+
813
+ def _discover_related_notes_commands(command_runner: _ObsidianCommandRunner) -> tuple[set[str], str]:
814
+ result = command_runner(["obsidian", "commands", "--json"])
815
+ if int(getattr(result, "returncode", 1) or 0) != 0:
816
+ if int(getattr(result, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE:
817
+ return set(), "timeout"
818
+ return set(RELATED_NOTES_COMMANDS.values()), "known_ids"
819
+ try:
820
+ parsed = json.loads(result.stdout or "")
821
+ except json.JSONDecodeError:
822
+ return set(RELATED_NOTES_COMMANDS.values()), "known_ids"
823
+ commands: set[str] = set()
824
+ if isinstance(parsed, list):
825
+ for item in parsed:
826
+ if isinstance(item, dict):
827
+ payload = JsonObjectAdapter.validate_python(item)
828
+ command_id = _json_str_field(payload, "id")
829
+ if command_id:
830
+ commands.add(command_id)
831
+ elif isinstance(item, str):
832
+ commands.add(item)
833
+ elif isinstance(parsed, dict):
834
+ payload = JsonObjectAdapter.validate_python(parsed)
835
+ for key, value in payload.items():
836
+ if key == "id" and value:
837
+ commands.add(str(value))
838
+ elif isinstance(value, dict):
839
+ command_id = _json_str_field(JsonObjectAdapter.validate_python(value), "id")
840
+ if command_id:
841
+ commands.add(command_id)
842
+ elif isinstance(value, list):
843
+ for item in value:
844
+ if not isinstance(item, dict):
845
+ continue
846
+ command_id = _json_str_field(JsonObjectAdapter.validate_python(item), "id")
847
+ if command_id:
848
+ commands.add(command_id)
849
+ return {command for command in commands if command.startswith(f"{RELATED_NOTES_PLUGIN_ID}:")}, "discovered"
850
+
851
+
852
+ def _blocked_export_validation(
853
+ export_path: Path,
854
+ wiki_dir: Path,
855
+ *,
856
+ blocked_reason: str,
857
+ next_action: str,
858
+ hash_errors: list[JsonObject] | None = None,
859
+ stale_notes: list[JsonObject] | None = None,
860
+ validation_errors: list[JsonObject] | None = None,
861
+ export_relocation: JsonObject | None = None,
862
+ extra: JsonObject | None = None,
863
+ ) -> _RelatedNotesExportValidation:
864
+ return _RelatedNotesExportValidation.model_validate(
865
+ {
866
+ "status": "blocked",
867
+ "export_path": export_path,
868
+ "wiki_dir": wiki_dir,
869
+ "blocked_reason": blocked_reason,
870
+ "next_action": next_action,
871
+ "hash_errors": hash_errors or [],
872
+ "stale_notes": stale_notes or [],
873
+ "validation_errors": validation_errors or [],
874
+ "export_relocation": export_relocation or {},
875
+ "extra_payload": extra or {},
876
+ }
877
+ )
878
+
879
+
880
+ def _json_object_list(items: Iterable[object]) -> list[JsonObject]:
881
+ """Validate a sequence of dict-like records before embedding in payloads."""
882
+
883
+ return [JsonObjectAdapter.validate_python(item) for item in items]
884
+
885
+
886
+ def _ready_export_validation(
887
+ export_path: Path,
888
+ wiki_dir: Path,
889
+ *,
890
+ payload: JsonObject,
891
+ notes: dict[str, RelatedNote],
892
+ edges: list[RelatedEdge],
893
+ hash_warnings: list[JsonObject] | None = None,
894
+ export_relocation: JsonObject | None = None,
895
+ ) -> _RelatedNotesExportValidation:
896
+ return _RelatedNotesExportValidation.model_validate(
897
+ {
898
+ "status": "ready",
899
+ "export_path": export_path,
900
+ "wiki_dir": wiki_dir,
901
+ "payload": payload,
902
+ "notes": notes,
903
+ "edges": edges,
904
+ "hash_warnings": hash_warnings or [],
905
+ "export_relocation": export_relocation or {},
906
+ }
907
+ )
908
+
909
+
910
+ def _recovery_payload(
911
+ export_path: Path,
912
+ wiki_dir: Path,
913
+ *,
914
+ status: str,
915
+ blocked_reason: str,
916
+ recovery_mode: str,
917
+ stale_notes: list[dict[str, str]],
918
+ next_action: str,
919
+ extra: JsonObject | None = None,
920
+ ) -> JsonObject:
921
+ reindex_id = RELATED_NOTES_COMMANDS["reindex_vault"]
922
+ export_id = RELATED_NOTES_COMMANDS["export_only_diagnostic"]
923
+ extra_payload = extra or {}
924
+ raw_headless = extra_payload["headless_export"] if "headless_export" in extra_payload else None
925
+ headless = (
926
+ RelatedNotesHeadlessExportSummary.model_validate(raw_headless)
927
+ if isinstance(raw_headless, dict)
928
+ else RelatedNotesHeadlessExportSummary()
929
+ )
930
+ return {
931
+ "schema": RELATED_NOTES_EXPORT_RECOVERY_SCHEMA,
932
+ "phase": "related_notes_export_recovery",
933
+ "status": status,
934
+ "blocked_reason": blocked_reason,
935
+ "next_action": next_action,
936
+ "required_inputs": RELATED_NOTES_REQUIRED_INPUTS,
937
+ "human_decision_required": False,
938
+ "wiki_dir": str(wiki_dir),
939
+ "export_path": str(export_path),
940
+ "stale_notes": stale_notes,
941
+ "stale_note_count": len(stale_notes),
942
+ "obsidian_cli_available": blocked_reason != "obsidian_cli_unavailable",
943
+ "obsidian_running": blocked_reason not in {
944
+ "obsidian_cli_unavailable",
945
+ "obsidian_not_ready",
946
+ "obsidian_cli_timeout",
947
+ },
948
+ "plugin_id": RELATED_NOTES_PLUGIN_ID,
949
+ "recovery_mode": recovery_mode,
950
+ "selected_recovery_mode": _public_recovery_mode(recovery_mode, blocked_reason=blocked_reason),
951
+ "manual_instruction_allowed": _manual_instruction_allowed(blocked_reason),
952
+ "api_calls": headless.embedded_count,
953
+ "api_failures": 0,
954
+ "obsidian_cli_reindex_command": f'obsidian vault="{wiki_dir.name}" command id="{reindex_id}"',
955
+ "obsidian_cli_export_only_command": f'obsidian vault="{wiki_dir.name}" command id="{export_id}"',
956
+ "retry_command": wiki_cli_command("run-linker", "--diagnose", "--json"),
957
+ "body_only_fallback": None,
958
+ **extra_payload,
959
+ }
960
+
961
+
962
+ def sync_related_notes(
963
+ config: MedConfig,
964
+ *,
965
+ export_path: Path | None = None,
966
+ apply: bool = False,
967
+ backup: bool = False,
968
+ receipt_path: Path | None = None,
969
+ min_score: float = DEFAULT_MIN_SCORE,
970
+ max_links: int = DEFAULT_MAX_LINKS,
971
+ max_age_hours: float = 168.0,
972
+ allow_stale_note_hashes: bool = False,
973
+ ) -> JsonObject:
974
+ result = sync_related_notes_operation_result(
975
+ config,
976
+ export_path=export_path,
977
+ apply=apply,
978
+ backup=backup,
979
+ receipt_path=receipt_path,
980
+ min_score=min_score,
981
+ max_links=max_links,
982
+ max_age_hours=max_age_hours,
983
+ allow_stale_note_hashes=allow_stale_note_hashes,
984
+ )
985
+ sync_result = LinkRelatedSyncResult.from_payload(result)
986
+ return link_related_fsm_payload_from_sync_result(
987
+ JsonObjectAdapter.validate_python(result),
988
+ run_id=_link_related_run_id(sync_result),
989
+ mode="apply" if apply else "dry_run",
990
+ version_control_safety=_link_related_version_control_safety(sync_result, applying=apply),
991
+ )
992
+
993
+
994
+ def sync_related_notes_operation_result(
995
+ config: MedConfig,
996
+ *,
997
+ export_path: Path | None = None,
998
+ apply: bool = False,
999
+ backup: bool = False,
1000
+ receipt_path: Path | None = None,
1001
+ min_score: float = DEFAULT_MIN_SCORE,
1002
+ max_links: int = DEFAULT_MAX_LINKS,
1003
+ max_age_hours: float = 168.0,
1004
+ allow_stale_note_hashes: bool = False,
1005
+ ) -> JsonObject:
1006
+ backup = False
1007
+ export = export_path or default_export_path(config.wiki_dir)
1008
+ blocked = _load_and_validate_export(
1009
+ export,
1010
+ config.wiki_dir,
1011
+ max_age_hours=max_age_hours,
1012
+ allow_stale_note_hashes=allow_stale_note_hashes,
1013
+ )
1014
+ if blocked.is_blocked:
1015
+ return blocked.blocked_payload()
1016
+
1017
+ payload = blocked.payload
1018
+ notes = blocked.notes
1019
+ edges = blocked.edges
1020
+ plan = _plan_related_note_updates(config.wiki_dir, notes, edges, min_score=min_score, max_links=max_links)
1021
+
1022
+ result = _base_payload(
1023
+ export,
1024
+ config.wiki_dir,
1025
+ status="preview_ready" if not apply else "completed",
1026
+ phase="related_notes_dry_run" if not apply else "related_notes_apply",
1027
+ blocked_reason="",
1028
+ next_action=(
1029
+ "Revisar updates e repetir com --apply --receipt para gravar."
1030
+ if not apply and plan.updates
1031
+ else ""
1032
+ ),
1033
+ extra={
1034
+ "source_export_schema": payload["schema"],
1035
+ "source_export_generated_at": payload["generated_at"],
1036
+ "plugin": payload["plugin"],
1037
+ "model": payload["model"],
1038
+ "min_score": min_score,
1039
+ "max_links": max_links,
1040
+ **_plan_summary(plan),
1041
+ "updates": plan.public_updates_payload(),
1042
+ "skipped_edges": plan.skipped_edges_payload(),
1043
+ "hash_warnings": blocked.hash_warnings,
1044
+ "export_relocation": blocked.export_relocation,
1045
+ },
1046
+ )
1047
+ if not apply:
1048
+ return result
1049
+
1050
+ applied_updates = _apply_updates(
1051
+ [update for update in plan.private_updates if update.changed],
1052
+ backup=backup,
1053
+ )
1054
+ receipt = _write_receipt(
1055
+ receipt_path or _default_receipt_path(),
1056
+ export_path=export,
1057
+ wiki_dir=config.wiki_dir,
1058
+ export_payload=payload,
1059
+ plan=result,
1060
+ applied_updates=applied_updates,
1061
+ )
1062
+ result.update(
1063
+ {
1064
+ "applied_note_count": len(applied_updates),
1065
+ "receipt_path": str(receipt),
1066
+ "updates": applied_updates,
1067
+ }
1068
+ )
1069
+ return result
1070
+
1071
+
1072
+ def cleanup_invalid_related_notes_links(
1073
+ config: MedConfig,
1074
+ *,
1075
+ backup: bool = False,
1076
+ cleanup_reason: str = "",
1077
+ ) -> JsonObject:
1078
+ """Remove broken links from generated Related Notes sections.
1079
+
1080
+ This is a degraded safety path for apply workflows when the plugin export
1081
+ cannot be refreshed. It does not invent new recommendations; it only keeps
1082
+ links that already point to one unique existing note.
1083
+ """
1084
+ notes_by_target = _notes_by_target(config.wiki_dir)
1085
+ reports: list[JsonObject] = []
1086
+ changed_files: list[str] = []
1087
+ backup_paths: list[str] = []
1088
+ removed_link_count = 0
1089
+ kept_link_count = 0
1090
+ for path in iter_notes(config.wiki_dir):
1091
+ relative = path.relative_to(config.wiki_dir).as_posix()
1092
+ text = path.read_text(encoding="utf-8")
1093
+ if _is_index_note(path, text):
1094
+ continue
1095
+ span = _related_section_span(text)
1096
+ if span is None:
1097
+ continue
1098
+ updated, report = _clean_related_section_text(
1099
+ text,
1100
+ span,
1101
+ source_relative_path=relative,
1102
+ notes_by_target=notes_by_target,
1103
+ )
1104
+ if not report.removed_links:
1105
+ continue
1106
+ removed_link_count += len(report.removed_links)
1107
+ kept_link_count += report.kept_link_count
1108
+ if updated == text:
1109
+ reports.append({"path": relative, "changed": False, **report.to_payload()})
1110
+ continue
1111
+ atomic_write_text(path, updated)
1112
+ changed_files.append(str(path))
1113
+ reports.append({"path": relative, "changed": True, "backup_path": "", **report.to_payload()})
1114
+ return {
1115
+ "schema": RELATED_NOTES_SAFETY_CLEANUP_SCHEMA,
1116
+ "phase": "related_notes_safety_cleanup",
1117
+ "status": "completed" if changed_files else "skipped",
1118
+ "cleanup_reason": cleanup_reason,
1119
+ "backup": backup,
1120
+ "changed_file_count": len(changed_files),
1121
+ "changed_files": changed_files,
1122
+ "backup_paths": backup_paths,
1123
+ "removed_link_count": removed_link_count,
1124
+ "kept_link_count": kept_link_count,
1125
+ "reports": reports,
1126
+ }
1127
+
1128
+
1129
+ def _load_and_validate_export(
1130
+ export_path: Path,
1131
+ wiki_dir: Path,
1132
+ *,
1133
+ max_age_hours: float,
1134
+ allow_stale_note_hashes: bool = False,
1135
+ ) -> _RelatedNotesExportValidation:
1136
+ if not export_path.is_file():
1137
+ return _blocked_export_validation(
1138
+ export_path,
1139
+ wiki_dir,
1140
+ blocked_reason="related_notes_export_missing",
1141
+ next_action=(
1142
+ "Exportar .obsidian/plugins/related-notes-obsidian/medical-notes-export.json "
1143
+ "ou passar --export para um arquivo related-notes-export.v1."
1144
+ ),
1145
+ )
1146
+ cache_key = _related_notes_export_cache_key(
1147
+ export_path,
1148
+ wiki_dir,
1149
+ max_age_hours=max_age_hours,
1150
+ allow_stale_note_hashes=allow_stale_note_hashes,
1151
+ )
1152
+ if cache_key is not None and cache_key in _RELATED_NOTES_EXPORT_CACHE:
1153
+ return _clone_export_validation_result(_RELATED_NOTES_EXPORT_CACHE[cache_key])
1154
+ try:
1155
+ payload = json.loads(export_path.read_text(encoding="utf-8"))
1156
+ except json.JSONDecodeError as exc:
1157
+ return _blocked_export_validation(
1158
+ export_path,
1159
+ wiki_dir,
1160
+ blocked_reason="related_notes_export_invalid_json",
1161
+ next_action=f"Corrigir JSON do export antes de repetir. Detalhe: {exc}",
1162
+ )
1163
+ if not isinstance(payload, dict):
1164
+ return _blocked_export_validation(
1165
+ export_path,
1166
+ wiki_dir,
1167
+ blocked_reason="related_notes_export_schema_invalid",
1168
+ next_action=f"Gerar export no schema {RELATED_NOTES_EXPORT_SCHEMA}.",
1169
+ )
1170
+ raw_payload = JsonObjectAdapter.validate_python(payload)
1171
+ if _json_str_field(raw_payload, "schema") != RELATED_NOTES_EXPORT_SCHEMA:
1172
+ return _blocked_export_validation(
1173
+ export_path,
1174
+ wiki_dir,
1175
+ blocked_reason="related_notes_export_schema_invalid",
1176
+ next_action=f"Gerar export no schema {RELATED_NOTES_EXPORT_SCHEMA}.",
1177
+ )
1178
+ forbidden = _find_forbidden_export_keys(payload)
1179
+ if forbidden:
1180
+ return _blocked_export_validation(
1181
+ export_path,
1182
+ wiki_dir,
1183
+ blocked_reason="related_notes_export_contains_private_payload",
1184
+ next_action="Gerar export sem API keys, tokens, conteúdo bruto, markdown ou embeddings.",
1185
+ extra={"forbidden_keys": forbidden[:12]},
1186
+ )
1187
+ try:
1188
+ contract = RelatedNotesExport.model_validate(payload)
1189
+ except PydanticValidationError as exc:
1190
+ return _blocked_export_validation(
1191
+ export_path,
1192
+ wiki_dir,
1193
+ blocked_reason="related_notes_export_contract_invalid",
1194
+ next_action=f"Gerar export no schema tipado {RELATED_NOTES_EXPORT_SCHEMA} pela rota oficial do plugin.",
1195
+ extra={"contract_errors": _contract_errors(exc)},
1196
+ )
1197
+ payload = contract.to_payload()
1198
+ vault_root = contract.vault_root.strip()
1199
+ vault_root_mismatch = not _same_root(vault_root, wiki_dir)
1200
+ age_error = _staleness_error(contract.generated_at.isoformat(), max_age_hours=max_age_hours)
1201
+ if age_error:
1202
+ return _blocked_export_validation(
1203
+ export_path,
1204
+ wiki_dir,
1205
+ blocked_reason="related_notes_export_stale",
1206
+ next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
1207
+ extra={"generated_at": payload["generated_at"], "max_age_hours": max_age_hours, "detail": age_error},
1208
+ )
1209
+
1210
+ notes_result = _parse_export_notes(contract, wiki_dir)
1211
+ if isinstance(notes_result, _RelatedNotesParseBlocked):
1212
+ return _blocked_parse_payload(export_path, wiki_dir, notes_result)
1213
+ notes = notes_result.notes
1214
+ profile_id = _export_profile_id(contract)
1215
+ hash_errors = _hash_errors(notes, profile_id=profile_id)
1216
+ if hash_errors and not allow_stale_note_hashes:
1217
+ export_relocation: JsonObject = {}
1218
+ if vault_root_mismatch:
1219
+ export_relocation = _export_relocation_payload(
1220
+ status="rejected",
1221
+ proof="relative_paths_and_representation_hashes",
1222
+ reason="hash_mismatch",
1223
+ note_count=len(notes),
1224
+ errors=hash_errors[:12],
1225
+ )
1226
+ return _blocked_export_validation(
1227
+ export_path,
1228
+ wiki_dir,
1229
+ blocked_reason="related_notes_hash_mismatch",
1230
+ next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
1231
+ hash_errors=_json_object_list(hash_errors[:12]),
1232
+ export_relocation=export_relocation,
1233
+ )
1234
+ if vault_root_mismatch:
1235
+ relocation_errors = _relocated_export_verification_errors(
1236
+ export_path=export_path,
1237
+ wiki_dir=wiki_dir,
1238
+ notes=notes,
1239
+ )
1240
+ if relocation_errors:
1241
+ return _blocked_export_validation(
1242
+ export_path,
1243
+ wiki_dir,
1244
+ blocked_reason="related_notes_vault_mismatch",
1245
+ next_action=(
1246
+ "Conferir o export do Related Notes: o caminho do vault mudou e a validação local "
1247
+ "não conseguiu provar que o export cobre esta Wiki."
1248
+ ),
1249
+ export_relocation=_export_relocation_payload(
1250
+ status="rejected",
1251
+ proof="relative_paths_and_representation_hashes",
1252
+ reason="coverage_or_location_mismatch",
1253
+ note_count=len(notes),
1254
+ errors=relocation_errors,
1255
+ ),
1256
+ )
1257
+
1258
+ edges_result = _parse_export_edges(contract, notes)
1259
+ if isinstance(edges_result, _RelatedNotesParseBlocked):
1260
+ return _blocked_parse_payload(export_path, wiki_dir, edges_result)
1261
+ export_relocation: JsonObject = {}
1262
+ if vault_root_mismatch:
1263
+ export_relocation = _export_relocation_payload(
1264
+ status="accepted",
1265
+ proof="relative_paths_and_representation_hashes",
1266
+ reason="vault_root_changed_but_relative_export_matches_current_wiki",
1267
+ note_count=len(notes),
1268
+ errors=[],
1269
+ )
1270
+ result = _ready_export_validation(
1271
+ export_path,
1272
+ wiki_dir,
1273
+ payload=payload,
1274
+ notes=notes,
1275
+ edges=edges_result.edges,
1276
+ hash_warnings=_json_object_list(hash_errors[:12]) if hash_errors else [],
1277
+ export_relocation=export_relocation,
1278
+ )
1279
+ if cache_key is not None:
1280
+ _store_export_validation_result(cache_key, result)
1281
+ return _clone_export_validation_result(result)
1282
+
1283
+
1284
+ def _related_notes_export_cache_key(
1285
+ export_path: Path,
1286
+ wiki_dir: Path,
1287
+ *,
1288
+ max_age_hours: float,
1289
+ allow_stale_note_hashes: bool,
1290
+ ) -> _RelatedNotesExportCacheKey | None:
1291
+ try:
1292
+ stat = export_path.stat()
1293
+ except OSError:
1294
+ return None
1295
+ return _RelatedNotesExportCacheKey(
1296
+ export_path=str(export_path.resolve(strict=False)),
1297
+ wiki_dir=str(wiki_dir.resolve(strict=False)),
1298
+ mtime_ns=stat.st_mtime_ns,
1299
+ size=stat.st_size,
1300
+ max_age_hours=float(max_age_hours),
1301
+ allow_stale_note_hashes=allow_stale_note_hashes,
1302
+ )
1303
+
1304
+
1305
+ def _store_export_validation_result(key: _RelatedNotesExportCacheKey, result: _RelatedNotesExportValidation) -> None:
1306
+ if len(_RELATED_NOTES_EXPORT_CACHE) >= _RELATED_NOTES_EXPORT_CACHE_MAX_ENTRIES:
1307
+ _RELATED_NOTES_EXPORT_CACHE.clear()
1308
+ _RELATED_NOTES_EXPORT_CACHE[key] = _clone_export_validation_result(result)
1309
+
1310
+
1311
+ def _clone_export_validation_result(result: _RelatedNotesExportValidation) -> _RelatedNotesExportValidation:
1312
+ return result.model_copy(
1313
+ update={
1314
+ "payload": dict(result.payload),
1315
+ "notes": dict(result.notes),
1316
+ "edges": list(result.edges),
1317
+ "hash_errors": list(result.hash_errors),
1318
+ "stale_notes": list(result.stale_notes),
1319
+ "validation_errors": list(result.validation_errors),
1320
+ "hash_warnings": list(result.hash_warnings),
1321
+ "export_relocation": dict(result.export_relocation),
1322
+ "extra_payload": dict(result.extra_payload),
1323
+ }
1324
+ )
1325
+
1326
+
1327
+ def _parse_export_notes(contract: RelatedNotesExport, wiki_dir: Path) -> _RelatedNotesNotesParseResult:
1328
+ """Validate exported note paths against the current vault."""
1329
+
1330
+ notes: dict[str, RelatedNote] = {}
1331
+ errors: list[JsonObject] = []
1332
+ stale_notes: list[JsonObject] = []
1333
+ for item in contract.notes:
1334
+ rel = _safe_relative_path(item.path)
1335
+ if rel is None:
1336
+ errors.append({"path": item.path, "error": "path must be relative inside wiki"})
1337
+ continue
1338
+ abs_path = (wiki_dir / rel).resolve(strict=False)
1339
+ if not _is_inside(abs_path, wiki_dir):
1340
+ errors.append({"path": item.path, "error": "path escapes wiki_dir"})
1341
+ continue
1342
+ if not abs_path.is_file():
1343
+ stale_notes.append(
1344
+ {
1345
+ "path": rel.as_posix(),
1346
+ "expected_hash": _normalize_hash(item.content_hash),
1347
+ "actual_hash": "missing",
1348
+ }
1349
+ )
1350
+ errors.append({"path": rel.as_posix(), "error": "note file missing"})
1351
+ continue
1352
+ notes[rel.as_posix()] = RelatedNote(
1353
+ rel_path=rel.as_posix(),
1354
+ abs_path=abs_path,
1355
+ title=item.title or abs_path.stem,
1356
+ content_hash=_normalize_hash(item.content_hash),
1357
+ )
1358
+ if errors:
1359
+ if stale_notes and len(stale_notes) == len(errors):
1360
+ return _RelatedNotesParseBlocked(
1361
+ blocked_reason="related_notes_export_stale",
1362
+ next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
1363
+ validation_errors=errors[:20],
1364
+ stale_notes=stale_notes[:20],
1365
+ )
1366
+ return _RelatedNotesParseBlocked(
1367
+ blocked_reason="related_notes_note_path_invalid",
1368
+ next_action="Corrigir paths relativos e hashes no export do plugin.",
1369
+ validation_errors=errors[:20],
1370
+ stale_notes=[],
1371
+ )
1372
+ return _RelatedNotesParsedNotes(notes=notes)
1373
+
1374
+
1375
+ def _parse_export_edges(
1376
+ contract: RelatedNotesExport,
1377
+ notes: dict[str, RelatedNote],
1378
+ ) -> _RelatedNotesEdgesParseResult:
1379
+ """Validate exported graph edges against the typed note map."""
1380
+
1381
+ edges: list[RelatedEdge] = []
1382
+ errors: list[JsonObject] = []
1383
+ for item in contract.edges:
1384
+ source_path = _safe_relative_path_string(item.source_path)
1385
+ target_path = _safe_relative_path_string(item.target_path)
1386
+ if not source_path or not target_path:
1387
+ errors.append({"edge": f"{item.source_path}->{item.target_path}", "error": "source_path and target_path must be relative"})
1388
+ continue
1389
+ if source_path not in notes or target_path not in notes:
1390
+ errors.append({"edge": f"{source_path}->{target_path}", "error": "edge references note missing from notes[]"})
1391
+ continue
1392
+ edges.append(
1393
+ RelatedEdge(
1394
+ source_path=source_path,
1395
+ target_path=target_path,
1396
+ score=item.score,
1397
+ rank=item.rank,
1398
+ source=item.source,
1399
+ )
1400
+ )
1401
+ if errors:
1402
+ return _RelatedNotesParseBlocked(
1403
+ blocked_reason="related_notes_edge_invalid",
1404
+ next_action="Corrigir edges para apontar apenas para notes[] válidas.",
1405
+ validation_errors=errors[:20],
1406
+ stale_notes=[],
1407
+ )
1408
+ return _RelatedNotesParsedEdges(edges=edges)
1409
+
1410
+
1411
+ def _relocated_export_verification_errors(
1412
+ *,
1413
+ export_path: Path,
1414
+ wiki_dir: Path,
1415
+ notes: dict[str, RelatedNote],
1416
+ ) -> list[JsonObject]:
1417
+ errors: list[JsonObject] = []
1418
+ if not _is_inside(export_path.resolve(strict=False), wiki_dir):
1419
+ errors.append({"code": "export_not_inside_current_wiki"})
1420
+
1421
+ exported_paths = set(notes)
1422
+ current_paths: set[str] = set()
1423
+ for path in iter_notes(wiki_dir):
1424
+ text = path.read_text(encoding="utf-8")
1425
+ if _is_index_note(path, text):
1426
+ continue
1427
+ current_paths.add(path.relative_to(wiki_dir).as_posix())
1428
+
1429
+ missing_paths = sorted(current_paths - exported_paths)
1430
+ extra_paths = sorted(exported_paths - current_paths)
1431
+ if not exported_paths and current_paths:
1432
+ errors.append({"code": "export_has_no_notes", "current_note_count": len(current_paths)})
1433
+ if missing_paths:
1434
+ errors.append({"code": "export_missing_current_notes", "paths": missing_paths[:20]})
1435
+ if extra_paths:
1436
+ errors.append({"code": "export_contains_non_current_notes", "paths": extra_paths[:20]})
1437
+ return errors
1438
+
1439
+
1440
+ def _export_relocation_payload(
1441
+ *,
1442
+ status: str,
1443
+ proof: str,
1444
+ reason: str,
1445
+ note_count: int,
1446
+ errors: list[JsonObject],
1447
+ ) -> JsonObject:
1448
+ return JsonObjectAdapter.validate_python(
1449
+ {
1450
+ "schema": "medical-notes-workbench.related-notes-export-relocation.v1",
1451
+ "status": status,
1452
+ "proof": proof,
1453
+ "reason": reason,
1454
+ "note_count": note_count,
1455
+ "uses_absolute_path_for_hash": False,
1456
+ "api_calls": 0,
1457
+ "embedding_calls": 0,
1458
+ "errors": errors,
1459
+ }
1460
+ )
1461
+
1462
+
1463
+ def _blocked_parse_payload(
1464
+ export_path: Path,
1465
+ wiki_dir: Path,
1466
+ parse_result: _RelatedNotesParseBlocked | object,
1467
+ ) -> _RelatedNotesExportValidation:
1468
+ typed_result = (
1469
+ parse_result
1470
+ if isinstance(parse_result, _RelatedNotesParseBlocked)
1471
+ else _RelatedNotesBlockedParseInput.model_validate(parse_result).to_result()
1472
+ )
1473
+ return _blocked_export_validation(
1474
+ export_path,
1475
+ wiki_dir,
1476
+ blocked_reason=typed_result.blocked_reason or "related_notes_export_invalid",
1477
+ next_action=typed_result.next_action or "Corrigir o export do plugin Related Notes.",
1478
+ validation_errors=typed_result.validation_errors,
1479
+ stale_notes=typed_result.stale_notes,
1480
+ )
1481
+
1482
+
1483
+ def _plan_related_note_updates(
1484
+ wiki_dir: Path,
1485
+ notes: dict[str, RelatedNote],
1486
+ edges: list[RelatedEdge],
1487
+ *,
1488
+ min_score: float,
1489
+ max_links: int,
1490
+ ) -> _RelatedNotesUpdatePlan:
1491
+ updates: list[_RelatedNotesPlannedUpdate] = []
1492
+ skipped_edges: list[_RelatedNotesSkippedEdge] = []
1493
+ by_source: dict[str, list[RelatedEdge]] = {}
1494
+ for edge in edges:
1495
+ by_source.setdefault(edge.source_path, []).append(edge)
1496
+
1497
+ title_counts: dict[str, int] = {}
1498
+ for note in notes.values():
1499
+ title_key = _link_key(note.title or note.abs_path.stem)
1500
+ title_counts[title_key] = (title_counts[title_key] if title_key in title_counts else 0) + 1
1501
+
1502
+ for index, (source_path, note) in enumerate(sorted(notes.items()), start=1):
1503
+ cooperative_cpu_yield(index)
1504
+ source_edges = by_source[source_path] if source_path in by_source else []
1505
+ text = note.abs_path.read_text(encoding="utf-8")
1506
+ span = _related_section_span(text)
1507
+ if span is None:
1508
+ skipped_edges.extend(
1509
+ _RelatedNotesSkippedEdge(
1510
+ source_path=edge.source_path,
1511
+ target_path=edge.target_path,
1512
+ reason="missing_related_section",
1513
+ )
1514
+ for edge in source_edges
1515
+ )
1516
+ if not source_edges:
1517
+ skipped_edges.append(_RelatedNotesSkippedEdge(source_path=source_path, reason="missing_related_section"))
1518
+ continue
1519
+ existing_targets = _existing_related_targets(text[span[1] : span[2]])
1520
+ proposed: list[_RelatedNotesProposedLink] = []
1521
+ for edge in sorted(
1522
+ source_edges,
1523
+ key=lambda item: (-item.score, item.rank, _link_key(notes[item.target_path].title), item.target_path),
1524
+ ):
1525
+ target = notes[edge.target_path]
1526
+ target_key = _link_key(target.title or target.abs_path.stem)
1527
+ if _link_key(note.title) == target_key:
1528
+ skipped_edges.append(
1529
+ _RelatedNotesSkippedEdge(
1530
+ source_path=edge.source_path,
1531
+ target_path=edge.target_path,
1532
+ reason="self_link",
1533
+ )
1534
+ )
1535
+ continue
1536
+ if edge.score < min_score:
1537
+ skipped_edges.append(
1538
+ _RelatedNotesSkippedEdge(
1539
+ source_path=edge.source_path,
1540
+ target_path=edge.target_path,
1541
+ reason="below_min_score",
1542
+ score=f"{edge.score:.4f}",
1543
+ )
1544
+ )
1545
+ continue
1546
+ if len(proposed) >= max_links:
1547
+ skipped_edges.append(
1548
+ _RelatedNotesSkippedEdge(
1549
+ source_path=edge.source_path,
1550
+ target_path=edge.target_path,
1551
+ reason="max_links_reached",
1552
+ )
1553
+ )
1554
+ continue
1555
+ proposed.append(
1556
+ _RelatedNotesProposedLink(
1557
+ target_path=target.rel_path,
1558
+ target_title=target.title,
1559
+ score=edge.score,
1560
+ rank=edge.rank,
1561
+ source=edge.source,
1562
+ content_hash=target.content_hash,
1563
+ line=_render_link_line(target, title_counts),
1564
+ )
1565
+ )
1566
+ new_text = _render_related_section_update(text, span, proposed)
1567
+ updates.append(
1568
+ _RelatedNotesPlannedUpdate(
1569
+ file=str(note.abs_path),
1570
+ relative_path=note.rel_path,
1571
+ source_title=note.title,
1572
+ content_hash=note.content_hash,
1573
+ cleared_links=sorted(existing_targets),
1574
+ cleared_link_count=len(existing_targets),
1575
+ proposed_links=proposed,
1576
+ new_content=new_text,
1577
+ changed=new_text != text,
1578
+ min_score=min_score,
1579
+ )
1580
+ )
1581
+ public_updates = [item.public_update() for item in updates if item.changed]
1582
+ return _RelatedNotesUpdatePlan(
1583
+ wiki_dir=str(wiki_dir),
1584
+ updates=public_updates,
1585
+ private_updates=updates,
1586
+ skipped_edges=skipped_edges,
1587
+ )
1588
+
1589
+
1590
+ def _related_section_span(text: str) -> tuple[int, int, int, int] | None:
1591
+ match = _RELATED_HEADING_RE.search(text)
1592
+ if not match:
1593
+ return None
1594
+ next_h2 = _NEXT_H2_RE.search(text, match.end())
1595
+ footer = _FOOTER_RE.search(text, match.end())
1596
+ candidates = [item.start() for item in (next_h2, footer) if item is not None]
1597
+ end = min(candidates) if candidates else len(text)
1598
+ return match.start(), match.end(), end, match.end()
1599
+
1600
+
1601
+ def _existing_related_targets(section_body: str) -> set[str]:
1602
+ targets: set[str] = set()
1603
+ for match in _WIKILINK_RE.finditer(section_body):
1604
+ raw = match.group(1).split("|", 1)[0].split("#", 1)[0].strip()
1605
+ if raw:
1606
+ targets.add(_link_key(obsidian_target_name(raw)))
1607
+ return targets
1608
+
1609
+
1610
+ def _notes_by_target(wiki_dir: Path) -> dict[str, list[str]]:
1611
+ notes: dict[str, list[str]] = {}
1612
+ for path in iter_notes(wiki_dir):
1613
+ text = path.read_text(encoding="utf-8")
1614
+ if _is_index_note(path, text):
1615
+ continue
1616
+ relative = path.relative_to(wiki_dir).as_posix()
1617
+ notes.setdefault(normalize_key(path.stem), []).append(relative)
1618
+ return notes
1619
+
1620
+
1621
+ class _RelatedSectionCleanupReport(ContractModel):
1622
+ """Report for deterministic cleanup of invalid Related Notes links."""
1623
+
1624
+ removed_links: list[JsonObject] = Field(default_factory=list)
1625
+ kept_link_count: int = Field(default=0, ge=0)
1626
+ cleared_to_marker: bool = Field(default=False, strict=True)
1627
+
1628
+
1629
+ def _clean_related_section_text(
1630
+ text: str,
1631
+ span: tuple[int, int, int, int],
1632
+ *,
1633
+ source_relative_path: str,
1634
+ notes_by_target: dict[str, list[str]],
1635
+ ) -> tuple[str, _RelatedSectionCleanupReport]:
1636
+ heading_start, _heading_end, section_end, content_start = span
1637
+ section_body = text[content_start:section_end]
1638
+ kept_lines: list[str] = []
1639
+ removed_links: list[JsonObject] = []
1640
+ seen: set[str] = set()
1641
+ for match in _WIKILINK_RE.finditer(section_body):
1642
+ raw = match.group(1).strip()
1643
+ target = obsidian_target_name(raw.split("|", 1)[0].split("#", 1)[0].strip())
1644
+ target_key = normalize_key(target)
1645
+ target_paths = notes_by_target[target_key] if target_key in notes_by_target else []
1646
+ reason = ""
1647
+ if not target or is_index_target(target):
1648
+ reason = "not_note_target"
1649
+ elif not target_paths:
1650
+ reason = "dangling_link"
1651
+ elif len(target_paths) > 1:
1652
+ reason = "ambiguous_link"
1653
+ elif target_paths[0] == source_relative_path:
1654
+ reason = "self_link"
1655
+ elif target_key in seen:
1656
+ reason = "duplicate_related_link"
1657
+ if reason:
1658
+ removed_links.append({"target": target, "raw": raw, "reason": reason})
1659
+ continue
1660
+ seen.add(target_key)
1661
+ kept_lines.append(f"- [[{raw}]]")
1662
+ rendered_lines = kept_lines or [f"- {NO_STRONG_LINKS_MARKER}"]
1663
+ section = "## 🔗 Notas Relacionadas\n" + "\n".join(rendered_lines).rstrip() + "\n\n"
1664
+ report = _RelatedSectionCleanupReport(
1665
+ removed_links=removed_links,
1666
+ kept_link_count=len(kept_lines),
1667
+ cleared_to_marker=not kept_lines,
1668
+ )
1669
+ return text[:heading_start] + section + text[section_end:], report
1670
+
1671
+
1672
+ def _render_link_line(target: RelatedNote, title_counts: dict[str, int]) -> str:
1673
+ title = target.title or target.abs_path.stem
1674
+ title_key = _link_key(title)
1675
+ if (title_counts[title_key] if title_key in title_counts else 0) > 1:
1676
+ target_without_suffix = target.rel_path[:-3] if target.rel_path.endswith(".md") else target.rel_path
1677
+ return f"- [[{target_without_suffix}|{title}]]"
1678
+ return f"- [[{title}]]"
1679
+
1680
+
1681
+ def _render_related_section_update(
1682
+ text: str,
1683
+ span: tuple[int, int, int, int],
1684
+ proposed: list[_RelatedNotesProposedLink],
1685
+ ) -> str:
1686
+ heading_start, _heading_end, section_end, content_start = span
1687
+ lines = [item.line for item in proposed] or [f"- {NO_STRONG_LINKS_MARKER}"]
1688
+ section = "## 🔗 Notas Relacionadas\n" + "\n".join(lines).rstrip() + "\n\n"
1689
+ return text[:heading_start] + section + text[section_end:]
1690
+
1691
+
1692
+ def _apply_updates(public_updates: list[_RelatedNotesPlannedUpdate], *, backup: bool) -> list[JsonObject]:
1693
+ applied: list[JsonObject] = []
1694
+ for update in public_updates:
1695
+ path = Path(update.file)
1696
+ atomic_write_text(path, update.new_content)
1697
+ public_update = update.public_update().to_payload()
1698
+ applied.append(
1699
+ {
1700
+ **public_update,
1701
+ "backup_path": "",
1702
+ "applied": True,
1703
+ }
1704
+ )
1705
+ return applied
1706
+
1707
+
1708
+ def _write_receipt(
1709
+ path: Path,
1710
+ *,
1711
+ export_path: Path,
1712
+ wiki_dir: Path,
1713
+ export_payload: JsonObject,
1714
+ plan: JsonObject,
1715
+ applied_updates: list[JsonObject],
1716
+ ) -> Path:
1717
+ path.parent.mkdir(parents=True, exist_ok=True)
1718
+ typed_plan = JsonObjectAdapter.validate_python(plan)
1719
+ typed_updates = _RelatedNotesAppliedUpdatesPayload.model_validate({"updates": applied_updates}).updates
1720
+ typed_export = RelatedNotesExport.model_validate(export_payload)
1721
+ sync_result = LinkRelatedSyncResult.from_payload(typed_plan)
1722
+ receipt = _RelatedNotesSyncReceiptPayload(
1723
+ schema=RELATED_NOTES_SYNC_RECEIPT_SCHEMA,
1724
+ generated_at=_now_iso(),
1725
+ status=sync_result.status or "completed",
1726
+ phase="related_notes_apply",
1727
+ dry_run=False,
1728
+ no_resource_mutation=len(typed_updates) == 0,
1729
+ wiki_dir=str(wiki_dir),
1730
+ export_path=str(export_path),
1731
+ export_hash="sha256:" + file_sha256(export_path),
1732
+ export_generated_at=typed_export.generated_at.isoformat(),
1733
+ plugin=typed_export.plugin.to_payload(),
1734
+ model=typed_export.model_info.to_payload(),
1735
+ api_calls=0,
1736
+ api_failures=0,
1737
+ plan_hash="sha256:" + canonical_json_hash({key: value for key, value in typed_plan.items() if key != "updates"}),
1738
+ applied_note_count=len(typed_updates),
1739
+ update_count=len(typed_updates),
1740
+ updates=typed_updates,
1741
+ ).to_payload()
1742
+ atomic_write_text(path, json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
1743
+ return path
1744
+
1745
+
1746
+ def _base_payload(
1747
+ export_path: Path,
1748
+ wiki_dir: Path,
1749
+ *,
1750
+ status: str,
1751
+ phase: str,
1752
+ blocked_reason: str,
1753
+ next_action: str,
1754
+ extra: JsonObject | None = None,
1755
+ ) -> JsonObject:
1756
+ selected_recovery_mode = (
1757
+ "reindex-vault"
1758
+ if blocked_reason in {"related_notes_hash_mismatch", "related_notes_export_stale"}
1759
+ else "manual"
1760
+ )
1761
+ extra_payload = JsonObjectAdapter.validate_python(extra or {})
1762
+ return JsonObjectAdapter.validate_python({
1763
+ "schema": RELATED_NOTES_SYNC_SCHEMA,
1764
+ "phase": phase,
1765
+ "status": status,
1766
+ "blocked_reason": blocked_reason,
1767
+ "next_action": next_action,
1768
+ "required_inputs": RELATED_NOTES_REQUIRED_INPUTS,
1769
+ "human_decision_required": False,
1770
+ "manual_instruction_allowed": _manual_instruction_allowed(blocked_reason),
1771
+ "selected_recovery_mode": selected_recovery_mode,
1772
+ "retry_command": wiki_cli_command("run-linker", "--diagnose", "--json"),
1773
+ "wiki_dir": str(wiki_dir),
1774
+ "export_path": str(export_path),
1775
+ **extra_payload,
1776
+ })
1777
+
1778
+
1779
+ def _plan_summary(plan: _RelatedNotesUpdatePlan) -> dict[str, int]:
1780
+ return plan.summary()
1781
+
1782
+
1783
+ def _contract_errors(exc: PydanticValidationError) -> list[JsonObject]:
1784
+ errors: list[JsonObject] = []
1785
+ for item in exc.errors()[:20]:
1786
+ loc_value = item["loc"] if "loc" in item else ()
1787
+ loc = ".".join(str(part) for part in loc_value) or "$"
1788
+ errors.append(
1789
+ {
1790
+ "loc": loc,
1791
+ "message": str(item["msg"] if "msg" in item else ""),
1792
+ "type": str(item["type"] if "type" in item else ""),
1793
+ }
1794
+ )
1795
+ return errors
1796
+
1797
+
1798
+ def _export_profile_id(contract: RelatedNotesExport) -> str:
1799
+ """Read the embedding profile from the validated export contract."""
1800
+
1801
+ return normalize_related_notes_profile_id(contract.model_info.embedding_profile_id)
1802
+
1803
+
1804
+ def _hash_errors(notes: dict[str, RelatedNote], *, profile_id: str) -> list[dict[str, str]]:
1805
+ errors: list[dict[str, str]] = []
1806
+ for note in notes.values():
1807
+ markdown = note.abs_path.read_text(encoding="utf-8")
1808
+ actual = related_notes_content_hash(
1809
+ path=note.rel_path,
1810
+ title=note.title or note.abs_path.stem,
1811
+ markdown=markdown,
1812
+ profile_id=profile_id,
1813
+ )
1814
+ if actual.lower() != note.content_hash.lower():
1815
+ legacy = (
1816
+ related_notes_legacy_clean_v1_content_hash(
1817
+ path=note.rel_path,
1818
+ title=note.title or note.abs_path.stem,
1819
+ markdown=markdown,
1820
+ )
1821
+ if profile_id == "clean_v1"
1822
+ else ""
1823
+ )
1824
+ if legacy and legacy.lower() == note.content_hash.lower():
1825
+ continue
1826
+ errors.append(
1827
+ {
1828
+ "path": note.rel_path,
1829
+ "expected": note.content_hash,
1830
+ "actual": actual,
1831
+ "hash_basis": "representation_hash",
1832
+ "embedding_profile_id": profile_id,
1833
+ }
1834
+ )
1835
+ return errors
1836
+
1837
+
1838
+ def _safe_relative_path(value: str) -> PurePosixPath | None:
1839
+ text = value.strip().replace("\\", "/")
1840
+ if not text or text.startswith(("/", "../")) or _WINDOWS_ABSOLUTE_RE.match(text):
1841
+ return None
1842
+ rel = PurePosixPath(text)
1843
+ if any(part in {"", ".", ".."} for part in rel.parts):
1844
+ return None
1845
+ return rel
1846
+
1847
+
1848
+ def _safe_relative_path_string(value: str) -> str:
1849
+ rel = _safe_relative_path(value)
1850
+ return rel.as_posix() if rel is not None else ""
1851
+
1852
+
1853
+ def _is_inside(path: Path, root: Path) -> bool:
1854
+ try:
1855
+ path.resolve(strict=False).relative_to(root.resolve(strict=False))
1856
+ return True
1857
+ except ValueError:
1858
+ return False
1859
+
1860
+
1861
+ def _same_root(value: str, wiki_dir: Path) -> bool:
1862
+ if not value:
1863
+ return False
1864
+ if value in {".", "./"}:
1865
+ return True
1866
+ if _WINDOWS_ABSOLUTE_RE.match(value) and not _WINDOWS_ABSOLUTE_RE.match(str(wiki_dir)):
1867
+ return False
1868
+ try:
1869
+ return Path(value).expanduser().resolve(strict=False) == wiki_dir.resolve(strict=False)
1870
+ except OSError:
1871
+ return False
1872
+
1873
+
1874
+ def _normalize_hash(value: str) -> str:
1875
+ text = value.strip().lower()
1876
+ if not text:
1877
+ return ""
1878
+ return text if text.startswith("sha256:") else "sha256:" + text
1879
+
1880
+
1881
+ def _find_forbidden_export_keys(value: object, *, prefix: str = "") -> list[str]:
1882
+ found: list[str] = []
1883
+ if isinstance(value, dict):
1884
+ for key, item in value.items():
1885
+ key_text = str(key)
1886
+ normalized = re.sub(r"[^a-z0-9]", "", key_text.lower())
1887
+ path = f"{prefix}.{key_text}" if prefix else key_text
1888
+ if normalized in _FORBIDDEN_EXPORT_KEYS:
1889
+ found.append(path)
1890
+ found.extend(_find_forbidden_export_keys(item, prefix=path))
1891
+ elif isinstance(value, list):
1892
+ for index, item in enumerate(value[:100]):
1893
+ found.extend(_find_forbidden_export_keys(item, prefix=f"{prefix}[{index}]"))
1894
+ return found
1895
+
1896
+
1897
+ def _staleness_error(value: str, *, max_age_hours: float) -> str:
1898
+ if max_age_hours <= 0:
1899
+ return ""
1900
+ try:
1901
+ generated = datetime.fromisoformat(value.replace("Z", "+00:00"))
1902
+ except ValueError:
1903
+ return "generated_at is not valid ISO-8601"
1904
+ if generated.tzinfo is None:
1905
+ generated = generated.replace(tzinfo=UTC)
1906
+ age_seconds = (datetime.now(UTC) - generated).total_seconds()
1907
+ if age_seconds < 0:
1908
+ return ""
1909
+ if age_seconds > max_age_hours * 3600:
1910
+ return f"export age is {age_seconds / 3600:.1f}h"
1911
+ return ""
1912
+
1913
+
1914
+ def _link_key(value: str) -> str:
1915
+ return re.sub(r"\s+", " ", str(value).strip()).casefold()
1916
+
1917
+
1918
+ def _default_receipt_path() -> Path:
1919
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
1920
+ return user_state_dir() / "runs" / stamp / "related-notes-receipt.json"