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.
- package/.opencode/agents/med-chat-triager.md +204 -0
- package/.opencode/agents/med-flashcard-maker.md +63 -0
- package/.opencode/agents/med-knowledge-architect.md +230 -0
- package/.opencode/agents/med-link-graph-curator.md +177 -0
- package/.opencode/agents/med-publish-guard.md +62 -0
- package/.opencode/commands/flashcards.md +25 -0
- package/.opencode/commands/mednotes/create.md +25 -0
- package/.opencode/commands/mednotes/enrich.md +27 -0
- package/.opencode/commands/mednotes/fix-wiki.md +27 -0
- package/.opencode/commands/mednotes/history.md +22 -0
- package/.opencode/commands/mednotes/link-body.md +25 -0
- package/.opencode/commands/mednotes/link-related.md +27 -0
- package/.opencode/commands/mednotes/link.md +27 -0
- package/.opencode/commands/mednotes/pdf-library.md +27 -0
- package/.opencode/commands/mednotes/process-chats.md +23 -0
- package/.opencode/commands/mednotes/setup.md +21 -0
- package/.opencode/commands/mednotes/status.md +27 -0
- package/.opencode/commands/mednotes/telemetry.md +27 -0
- package/.opencode/commands/report.md +26 -0
- package/.opencode/mednotes/AGENTS.md +57 -0
- package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
- package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
- package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
- package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
- package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
- package/.opencode/mednotes/contracts/.gitkeep +1 -0
- package/.opencode/mednotes/contracts/agents.json +116 -0
- package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
- package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
- package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
- package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
- package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
- package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
- package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
- package/.opencode/mednotes/docs/extension-docs.md +40 -0
- package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
- package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
- package/.opencode/mednotes/docs/merge-policy.md +110 -0
- package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
- package/.opencode/mednotes/docs/semantic-linker.md +141 -0
- package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
- package/.opencode/mednotes/docs/triage-policy.md +187 -0
- package/.opencode/mednotes/docs/vault-version-control.md +758 -0
- package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
- package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
- package/.opencode/mednotes/hooks/hooks.json +79 -0
- package/.opencode/mednotes/package-lock.json +6361 -0
- package/.opencode/mednotes/package.json +15 -0
- package/.opencode/mednotes/pyproject.toml +48 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
- package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
- package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
- package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
- package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
- package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
- package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
- package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
- package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
- package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
- package/.opencode/mednotes/scripts/run_python.mjs +109 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
- package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
- package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
- package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
- package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
- package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
- package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
- package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
- package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
- package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
- package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
- package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
- package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
- package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
- package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
- package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
- package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
- package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
- package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
- package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
- package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
- package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
- package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
- package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
- package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
- package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
- package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
- package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
- package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
- package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
- package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
- package/.opencode/mednotes/uv.lock +932 -0
- package/.opencode/mednotes.generated.json +395 -0
- package/.opencode/opencode.json +31 -0
- package/.opencode/plugins/mednotes-fsm.mjs +7 -0
- package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
- package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
- package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
- package/AGENTS.md +57 -0
- package/README.md +194 -0
- package/adapters/antigravity/agents.json +80 -0
- package/adapters/antigravity/templates/med-chat-triager.md +214 -0
- package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
- package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
- package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
- package/adapters/antigravity/templates/med-publish-guard.md +71 -0
- package/adapters/gemini-cli/gemini-extension.json +14 -0
- package/adapters/gemini-cli/package.json +15 -0
- package/adapters/gemini-cli/pyproject.toml +48 -0
- package/bin/mednotes-opencode.mjs +155 -0
- package/contracts/agents.json +116 -0
- package/core/agents/med-chat-triager.md +197 -0
- package/core/agents/med-flashcard-maker.md +56 -0
- package/core/agents/med-knowledge-architect.md +224 -0
- package/core/agents/med-link-graph-curator.md +171 -0
- package/core/agents/med-publish-guard.md +55 -0
- package/core/commands/flashcards.toml +22 -0
- package/core/commands/mednotes/create.toml +22 -0
- package/core/commands/mednotes/enrich.toml +24 -0
- package/core/commands/mednotes/fix-wiki.toml +24 -0
- package/core/commands/mednotes/history.toml +19 -0
- package/core/commands/mednotes/link-body.toml +22 -0
- package/core/commands/mednotes/link-related.toml +24 -0
- package/core/commands/mednotes/link.toml +24 -0
- package/core/commands/mednotes/pdf-library.toml +24 -0
- package/core/commands/mednotes/process-chats.toml +20 -0
- package/core/commands/mednotes/setup.toml +18 -0
- package/core/commands/mednotes/status.toml +24 -0
- package/core/commands/mednotes/telemetry.toml +24 -0
- package/core/commands/report.toml +23 -0
- package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/core/skills/create-medical-flashcards/SKILL.md +113 -0
- package/core/skills/create-medical-note/SKILL.md +90 -0
- package/core/skills/enrich-medical-note/SKILL.md +120 -0
- package/core/skills/fix-medical-wiki/SKILL.md +559 -0
- package/core/skills/link-medical-wiki/SKILL.md +224 -0
- package/core/skills/obsidian-cli/SKILL.md +118 -0
- package/core/skills/obsidian-markdown/SKILL.md +207 -0
- package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/core/skills/obsidian-ops/SKILL.md +136 -0
- package/core/skills/pdf-library/SKILL.md +45 -0
- package/core/skills/process-medical-chats/SKILL.md +246 -0
- package/core/skills/workflow-report/SKILL.md +100 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
"""Shared path resolution for MedNotes.
|
|
2
|
+
|
|
3
|
+
User-specific paths must live in persistent MedNotes state, not in generated
|
|
4
|
+
runtime bundles or workflow code. This module centralizes that rule for Wiki,
|
|
5
|
+
flashcard, Obsidian and enrichment workflows.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import tomllib
|
|
23
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
|
|
24
|
+
tomllib = None
|
|
25
|
+
|
|
26
|
+
from mednotes.kernel.workflow import DecisionEvidence, HumanDecisionPacket, RejectedAutomation, WorkflowDecision
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extension_root() -> Path:
|
|
30
|
+
"""Raiz do bundle distribuído — a pasta que contém ``scripts/`` e ``src/``.
|
|
31
|
+
|
|
32
|
+
Fonte ÚNICA da resolução (ADR-0001 regra 10): em vez de cada módulo contar
|
|
33
|
+
níveis com ``parents[N]`` (que quebram quando o módulo muda de profundidade),
|
|
34
|
+
todos chamam isto. Acha o ancestral certo, logo funciona no repo (``bundle/``)
|
|
35
|
+
e no artefato (``dist/``).
|
|
36
|
+
"""
|
|
37
|
+
here = Path(__file__).resolve()
|
|
38
|
+
for parent in here.parents:
|
|
39
|
+
if (parent / "scripts").is_dir() and (parent / "src").is_dir():
|
|
40
|
+
return parent
|
|
41
|
+
return here.parents[4]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
APP_DIR_NAME = ".mednotes"
|
|
45
|
+
APP_HOME_ENV_VARS = ("MEDNOTES_HOME",)
|
|
46
|
+
CONFIG_ENV_VARS = ("MEDNOTES_CONFIG",)
|
|
47
|
+
GEMINI_MEMORY_ENV_VARS = ("MEDNOTES_GEMINI_MEMORY", "MEDICAL_NOTES_GEMINI_MEMORY")
|
|
48
|
+
PATHS_SCHEMA = "medical-notes-workbench.paths.v1"
|
|
49
|
+
ENVIRONMENT_PREFLIGHT_SCHEMA = "medical-notes-workbench.environment-preflight.v1"
|
|
50
|
+
CONFIG_REPAIR_SCHEMA = "medical-notes-workbench.config-template-repair.v1"
|
|
51
|
+
ENVIRONMENT_BLOCKER_CODE = "environment_blocker.windows_path_or_venv"
|
|
52
|
+
GEMINI_PATH_PROBE_SCHEMA = "medical-notes-workbench.gemini-path-probe.v1"
|
|
53
|
+
DEFAULT_CATALOG_PATH = "~/.mednotes/CATALOGO_WIKI.json"
|
|
54
|
+
DEFAULT_RAW_DIR = "~/.mednotes/Chats_Raw"
|
|
55
|
+
_WINDOWS_PROMPT_FILE_THRESHOLD = 6000
|
|
56
|
+
|
|
57
|
+
_PATHS_BLOCK_RE = re.compile(
|
|
58
|
+
rf"(?ms)^```[ \t]*toml[^\n]*\b{re.escape(PATHS_SCHEMA)}\b[^\n]*\n(?P<body>.*?)^```[ \t]*$"
|
|
59
|
+
)
|
|
60
|
+
_WINDOWS_PATH_STRING_RE = re.compile(
|
|
61
|
+
r'(?m)^(\s*(?:wiki_dir|raw_dir|path|catalog_path|vocabulary_db_path)\s*=\s*)"([^"\n]*\\[^"\n]*)"'
|
|
62
|
+
)
|
|
63
|
+
_MOJIBAKE_MARKERS = (
|
|
64
|
+
"á",
|
|
65
|
+
"â",
|
|
66
|
+
"ã",
|
|
67
|
+
"ç",
|
|
68
|
+
"é",
|
|
69
|
+
"ê",
|
|
70
|
+
"Ã",
|
|
71
|
+
"ó",
|
|
72
|
+
"ô",
|
|
73
|
+
"ú",
|
|
74
|
+
"Ç",
|
|
75
|
+
"É",
|
|
76
|
+
"—",
|
|
77
|
+
"–",
|
|
78
|
+
"“",
|
|
79
|
+
"â€",
|
|
80
|
+
"´",
|
|
81
|
+
"�",
|
|
82
|
+
)
|
|
83
|
+
_MARKDOWN_AT_REF_RE = re.compile(r"@([^\s)]+\.md)\b", re.IGNORECASE)
|
|
84
|
+
_MARKDOWN_LINK_REF_RE = re.compile(r"\]\(([^)#?]+\.md)(?:[#?][^)]*)?\)", re.IGNORECASE)
|
|
85
|
+
_BARE_MARKDOWN_CONTEXT_REF_RE = re.compile(r"\b[\w.-]+\.md\b", re.IGNORECASE)
|
|
86
|
+
# Probe hints for identifying a Wiki root; taxonomy policy lives in
|
|
87
|
+
# bundle/scripts/mednotes/wiki/taxonomy/policy.py.
|
|
88
|
+
_WIKI_DIR_PROBE_TOP_LEVEL_HINTS = {
|
|
89
|
+
"1. Clínica Médica",
|
|
90
|
+
"1. Clinica Medica",
|
|
91
|
+
"2. Cirurgia",
|
|
92
|
+
"3. Ginecologia e Obstetrícia",
|
|
93
|
+
"3. Ginecologia e Obstetricia",
|
|
94
|
+
"4. Pediatria",
|
|
95
|
+
"5. Preventiva e Saúde Coletiva",
|
|
96
|
+
"5. Preventiva e Saude Coletiva",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class PathCandidate:
|
|
102
|
+
path: Path
|
|
103
|
+
source: str
|
|
104
|
+
exists: bool
|
|
105
|
+
is_dir: bool
|
|
106
|
+
reason: str = ""
|
|
107
|
+
compat_warning: str = ""
|
|
108
|
+
raw_dir: Path | None = None
|
|
109
|
+
confidence: str = ""
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> dict[str, object]:
|
|
112
|
+
data: dict[str, object] = {
|
|
113
|
+
"path": str(self.path),
|
|
114
|
+
"source": self.source,
|
|
115
|
+
"exists": self.exists,
|
|
116
|
+
"is_dir": self.is_dir,
|
|
117
|
+
}
|
|
118
|
+
if self.reason:
|
|
119
|
+
data["reason"] = self.reason
|
|
120
|
+
if self.compat_warning:
|
|
121
|
+
data["compat_warning"] = self.compat_warning
|
|
122
|
+
if self.raw_dir is not None:
|
|
123
|
+
data["raw_dir"] = str(self.raw_dir)
|
|
124
|
+
if self.confidence:
|
|
125
|
+
data["confidence"] = self.confidence
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class WikiPathResolution:
|
|
131
|
+
path: Path | None
|
|
132
|
+
source: str
|
|
133
|
+
memory_path: Path
|
|
134
|
+
config_path: Path | None
|
|
135
|
+
candidates: tuple[PathCandidate, ...] = ()
|
|
136
|
+
compat_warnings: tuple[str, ...] = ()
|
|
137
|
+
blocked_reason: str = ""
|
|
138
|
+
next_action: str = ""
|
|
139
|
+
required_inputs: tuple[str, ...] = ("wiki_dir",)
|
|
140
|
+
human_decision_packet: HumanDecisionPacket | None = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def ok(self) -> bool:
|
|
144
|
+
return self.path is not None and not self.blocked_reason
|
|
145
|
+
|
|
146
|
+
def as_payload(self, *, phase: str = "resolve_wiki_dir") -> dict[str, object]:
|
|
147
|
+
payload: dict[str, object] = {
|
|
148
|
+
"schema": "medical-notes-workbench.path-resolution.v1",
|
|
149
|
+
"phase": phase,
|
|
150
|
+
"status": "completed" if self.ok else "blocked",
|
|
151
|
+
"blocked_reason": self.blocked_reason,
|
|
152
|
+
"next_action": self.next_action,
|
|
153
|
+
"required_inputs": list(self.required_inputs),
|
|
154
|
+
"wiki_dir": str(self.path) if self.path else "",
|
|
155
|
+
"wiki_source": self.source,
|
|
156
|
+
"wiki_dir_source": self.source,
|
|
157
|
+
"memory_path": str(self.memory_path),
|
|
158
|
+
"config_path": str(self.config_path) if self.config_path else "",
|
|
159
|
+
"candidates": [candidate.as_dict() for candidate in self.candidates],
|
|
160
|
+
"compat_warnings": list(self.compat_warnings),
|
|
161
|
+
"human_decision_required": self.human_decision_packet is not None,
|
|
162
|
+
}
|
|
163
|
+
if self.human_decision_packet is not None:
|
|
164
|
+
packet = _human_decision_packet_payload(self.human_decision_packet)
|
|
165
|
+
payload["human_decision_packet"] = packet
|
|
166
|
+
payload["human_decision_packets"] = [packet]
|
|
167
|
+
return payload
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def expand_path(value: str | os.PathLike[str]) -> Path:
|
|
171
|
+
return Path(os.path.expandvars(str(value))).expanduser()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _human_decision_packet_payload(packet: HumanDecisionPacket) -> dict[str, object]:
|
|
175
|
+
"""Serialize typed human-decision packets at the public JSON edge."""
|
|
176
|
+
|
|
177
|
+
return packet.model_dump(mode="json", by_alias=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def user_state_dir() -> Path:
|
|
181
|
+
for env_name in APP_HOME_ENV_VARS:
|
|
182
|
+
value = os.environ.get(env_name)
|
|
183
|
+
if value:
|
|
184
|
+
return expand_path(value)
|
|
185
|
+
return Path.home() / APP_DIR_NAME
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def default_config_path() -> Path:
|
|
189
|
+
return user_state_dir() / "config.toml"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def persistent_gemini_path() -> Path:
|
|
193
|
+
for env_name in GEMINI_MEMORY_ENV_VARS:
|
|
194
|
+
value = os.environ.get(env_name)
|
|
195
|
+
if value:
|
|
196
|
+
return expand_path(value)
|
|
197
|
+
return Path.home() / ".gemini" / "GEMINI.md"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def find_config(explicit: str | os.PathLike[str] | None = None, *, start: Path | None = None) -> Path | None:
|
|
201
|
+
if explicit:
|
|
202
|
+
return expand_path(explicit)
|
|
203
|
+
|
|
204
|
+
for env_name in CONFIG_ENV_VARS:
|
|
205
|
+
value = os.environ.get(env_name)
|
|
206
|
+
if value:
|
|
207
|
+
return expand_path(value)
|
|
208
|
+
|
|
209
|
+
if any(os.environ.get(env_name) for env_name in APP_HOME_ENV_VARS):
|
|
210
|
+
return default_config_path()
|
|
211
|
+
|
|
212
|
+
cur = (start or Path.cwd()).resolve()
|
|
213
|
+
for directory in (cur, *cur.parents):
|
|
214
|
+
candidate = directory / "config.toml"
|
|
215
|
+
if candidate.is_file():
|
|
216
|
+
return candidate
|
|
217
|
+
|
|
218
|
+
user_config = default_config_path()
|
|
219
|
+
if user_config.is_file():
|
|
220
|
+
return user_config
|
|
221
|
+
return user_config
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def read_toml(path: Path | None) -> dict[str, Any]:
|
|
225
|
+
if not path or not path.exists():
|
|
226
|
+
return {}
|
|
227
|
+
if tomllib is None:
|
|
228
|
+
raise RuntimeError("tomllib unavailable; use Python 3.11+ for TOML support")
|
|
229
|
+
return _loads_toml_with_windows_path_fallback(path.read_text(encoding="utf-8"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _loads_toml_with_windows_path_fallback(text: str) -> dict[str, Any]:
|
|
233
|
+
if tomllib is None:
|
|
234
|
+
raise RuntimeError("tomllib unavailable; use Python 3.11+ for TOML support")
|
|
235
|
+
toml = tomllib
|
|
236
|
+
try:
|
|
237
|
+
return toml.loads(text)
|
|
238
|
+
except toml.TOMLDecodeError:
|
|
239
|
+
repaired = _WINDOWS_PATH_STRING_RE.sub(
|
|
240
|
+
lambda match: match.group(1) + json.dumps(match.group(2).replace("\\", "/"), ensure_ascii=False),
|
|
241
|
+
text,
|
|
242
|
+
)
|
|
243
|
+
if repaired == text:
|
|
244
|
+
raise
|
|
245
|
+
return toml.loads(repaired)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def config_encoding_warnings(path: Path | None) -> list[dict[str, Any]]:
|
|
249
|
+
if not path or not path.exists():
|
|
250
|
+
return []
|
|
251
|
+
raw = path.read_bytes()
|
|
252
|
+
try:
|
|
253
|
+
text = raw.decode("utf-8")
|
|
254
|
+
except UnicodeDecodeError as exc:
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
"code": "config_encoding.not_utf8",
|
|
258
|
+
"path": str(path),
|
|
259
|
+
"detail": str(exc),
|
|
260
|
+
"next_action": "Rodar repair-config-template --json para recriar config.toml como UTF-8; nao editar manualmente durante o workflow.",
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
markers = sorted({marker for marker in _MOJIBAKE_MARKERS if marker in text})
|
|
264
|
+
if not markers:
|
|
265
|
+
return []
|
|
266
|
+
return [
|
|
267
|
+
{
|
|
268
|
+
"code": "config_encoding.possible_mojibake",
|
|
269
|
+
"path": str(path),
|
|
270
|
+
"markers": markers[:8],
|
|
271
|
+
"next_action": "Rodar repair-config-template --json para recriar config.toml a partir do template UTF-8; set-paths deve alterar apenas [paths].",
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def read_persistent_paths(memory_path: Path | None = None) -> tuple[dict[str, str], str]:
|
|
277
|
+
path = memory_path or persistent_gemini_path()
|
|
278
|
+
if not path.exists():
|
|
279
|
+
return {}, ""
|
|
280
|
+
text = path.read_text(encoding="utf-8")
|
|
281
|
+
match = _PATHS_BLOCK_RE.search(text)
|
|
282
|
+
if not match:
|
|
283
|
+
return {}, ""
|
|
284
|
+
if tomllib is None:
|
|
285
|
+
return {}, "tomllib unavailable; use Python 3.11+ for legacy GEMINI.md paths"
|
|
286
|
+
try:
|
|
287
|
+
data = _loads_toml_with_windows_path_fallback(match.group("body"))
|
|
288
|
+
except tomllib.TOMLDecodeError as exc:
|
|
289
|
+
return {}, str(exc)
|
|
290
|
+
paths = data.get("paths", {}) if isinstance(data.get("paths"), dict) else {}
|
|
291
|
+
return {
|
|
292
|
+
key: str(value).strip()
|
|
293
|
+
for key, value in paths.items()
|
|
294
|
+
if key in {"wiki_dir", "raw_dir"} and isinstance(value, str) and value.strip()
|
|
295
|
+
}, ""
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def resolve_wiki_dir(
|
|
299
|
+
*,
|
|
300
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
301
|
+
config: str | os.PathLike[str] | None = None,
|
|
302
|
+
start: Path | None = None,
|
|
303
|
+
context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None = None,
|
|
304
|
+
enable_gemini_probe: bool = False,
|
|
305
|
+
) -> WikiPathResolution:
|
|
306
|
+
memory_path = persistent_gemini_path()
|
|
307
|
+
config_path = find_config(config, start=start)
|
|
308
|
+
|
|
309
|
+
def maybe_probe(resolution: WikiPathResolution) -> WikiPathResolution:
|
|
310
|
+
return _maybe_gemini_path_probe(
|
|
311
|
+
resolution,
|
|
312
|
+
enabled=enable_gemini_probe,
|
|
313
|
+
start=start,
|
|
314
|
+
context_paths=context_paths,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if explicit:
|
|
318
|
+
path = expand_path(explicit).resolve()
|
|
319
|
+
return WikiPathResolution(
|
|
320
|
+
path=path,
|
|
321
|
+
source="cli",
|
|
322
|
+
memory_path=memory_path,
|
|
323
|
+
config_path=config_path,
|
|
324
|
+
candidates=(_candidate(path, "cli", "explicit --wiki-dir"),),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
env_candidate = _env_wiki_candidate()
|
|
328
|
+
if env_candidate is not None:
|
|
329
|
+
if env_candidate.exists and env_candidate.is_dir:
|
|
330
|
+
return WikiPathResolution(
|
|
331
|
+
path=env_candidate.path,
|
|
332
|
+
source=env_candidate.source,
|
|
333
|
+
memory_path=memory_path,
|
|
334
|
+
config_path=config_path,
|
|
335
|
+
candidates=(env_candidate,),
|
|
336
|
+
compat_warnings=(env_candidate.compat_warning,) if env_candidate.compat_warning else (),
|
|
337
|
+
)
|
|
338
|
+
return maybe_probe(
|
|
339
|
+
_blocked(
|
|
340
|
+
"env_wiki_dir_invalid",
|
|
341
|
+
"Corrigir MED_WIKI_DIR para uma pasta existente ou remover a variavel e configurar o TOML do app.",
|
|
342
|
+
memory_path,
|
|
343
|
+
config_path,
|
|
344
|
+
candidates=(env_candidate,),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
config_candidate = _config_wiki_candidate(config_path)
|
|
349
|
+
if config_candidate is not None:
|
|
350
|
+
candidate = config_candidate
|
|
351
|
+
if candidate.exists and candidate.is_dir:
|
|
352
|
+
return WikiPathResolution(
|
|
353
|
+
path=candidate.path,
|
|
354
|
+
source=candidate.source,
|
|
355
|
+
memory_path=memory_path,
|
|
356
|
+
config_path=config_path,
|
|
357
|
+
candidates=(candidate,),
|
|
358
|
+
)
|
|
359
|
+
return maybe_probe(
|
|
360
|
+
_blocked(
|
|
361
|
+
"config_wiki_dir_invalid",
|
|
362
|
+
f"Atualizar {config_path or default_config_path()} [paths].wiki_dir com uma pasta existente.",
|
|
363
|
+
memory_path,
|
|
364
|
+
config_path,
|
|
365
|
+
candidates=(candidate,),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
contextual = _contextual_candidates(start=start, context_paths=context_paths)
|
|
370
|
+
distinct_contextual = _distinct_candidates(contextual)
|
|
371
|
+
if len(distinct_contextual) == 1:
|
|
372
|
+
candidate = distinct_contextual[0]
|
|
373
|
+
return WikiPathResolution(
|
|
374
|
+
path=candidate.path,
|
|
375
|
+
source=candidate.source,
|
|
376
|
+
memory_path=memory_path,
|
|
377
|
+
config_path=config_path,
|
|
378
|
+
candidates=tuple(contextual),
|
|
379
|
+
)
|
|
380
|
+
if len(distinct_contextual) > 1:
|
|
381
|
+
return _blocked(
|
|
382
|
+
"ambiguous_wiki_dir",
|
|
383
|
+
f"Registrar o wiki_dir correto em {config_path or default_config_path()} [paths].wiki_dir.",
|
|
384
|
+
memory_path,
|
|
385
|
+
config_path,
|
|
386
|
+
candidates=tuple(contextual),
|
|
387
|
+
human_decision_packet=_wiki_path_choice_packet(tuple(contextual), config_path or default_config_path()),
|
|
388
|
+
)
|
|
389
|
+
return maybe_probe(
|
|
390
|
+
_blocked(
|
|
391
|
+
"missing_wiki_dir",
|
|
392
|
+
f"Rodar set-paths ou adicionar [paths].wiki_dir em {config_path or default_config_path()}.",
|
|
393
|
+
memory_path,
|
|
394
|
+
config_path,
|
|
395
|
+
candidates=(),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def resolve_raw_dir(
|
|
401
|
+
*,
|
|
402
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
403
|
+
config: str | os.PathLike[str] | None = None,
|
|
404
|
+
start: Path | None = None,
|
|
405
|
+
) -> Path:
|
|
406
|
+
if explicit:
|
|
407
|
+
return expand_path(explicit)
|
|
408
|
+
env_value = os.getenv("MED_RAW_DIR")
|
|
409
|
+
if env_value:
|
|
410
|
+
return expand_path(env_value)
|
|
411
|
+
config_path = find_config(config, start=start)
|
|
412
|
+
cfg = read_toml(config_path)
|
|
413
|
+
paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
|
|
414
|
+
if paths.get("raw_dir"):
|
|
415
|
+
return expand_path(str(paths["raw_dir"]))
|
|
416
|
+
return expand_path(DEFAULT_RAW_DIR)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def plan_set_paths(
|
|
420
|
+
*,
|
|
421
|
+
config: str | os.PathLike[str] | Path | None = None,
|
|
422
|
+
wiki_dir: str | os.PathLike[str] | Path | None,
|
|
423
|
+
raw_dir: str | os.PathLike[str] | Path | None,
|
|
424
|
+
agent_repair: bool = False,
|
|
425
|
+
) -> dict[str, Any]:
|
|
426
|
+
config_path = find_config(config) or default_config_path()
|
|
427
|
+
wiki_path = expand_path(wiki_dir).resolve(strict=False) if wiki_dir else None
|
|
428
|
+
raw_path = expand_path(raw_dir).resolve(strict=False) if raw_dir else None
|
|
429
|
+
errors: list[dict[str, str]] = []
|
|
430
|
+
|
|
431
|
+
if wiki_path is None:
|
|
432
|
+
errors.append({"field": "wiki_dir", "reason": "missing"})
|
|
433
|
+
elif not wiki_path.exists() or not wiki_path.is_dir():
|
|
434
|
+
errors.append({"field": "wiki_dir", "path": str(wiki_path), "reason": "not_existing_directory"})
|
|
435
|
+
|
|
436
|
+
if raw_path is None:
|
|
437
|
+
errors.append({"field": "raw_dir", "reason": "missing"})
|
|
438
|
+
elif not raw_path.exists() or not raw_path.is_dir():
|
|
439
|
+
errors.append({"field": "raw_dir", "path": str(raw_path), "reason": "not_existing_directory"})
|
|
440
|
+
|
|
441
|
+
if errors:
|
|
442
|
+
return {
|
|
443
|
+
"schema": "medical-notes-workbench.set-paths.v1",
|
|
444
|
+
"phase": "set-paths",
|
|
445
|
+
"status": "blocked",
|
|
446
|
+
"blocked_reason": "path_validation_failed",
|
|
447
|
+
"next_action": "Escolher pastas existentes para Wiki_Medicina e Chats_Raw antes de persistir os caminhos.",
|
|
448
|
+
"required_inputs": ["wiki_dir", "raw_dir"],
|
|
449
|
+
"human_decision_required": False,
|
|
450
|
+
"config_path": str(config_path),
|
|
451
|
+
"wiki_dir": str(wiki_path) if wiki_path else "",
|
|
452
|
+
"raw_dir": str(raw_path) if raw_path else "",
|
|
453
|
+
"errors": errors,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
assert wiki_path is not None
|
|
457
|
+
assert raw_path is not None
|
|
458
|
+
encoding_warnings = config_encoding_warnings(config_path)
|
|
459
|
+
if any(warning.get("code") == "config_encoding.not_utf8" for warning in encoding_warnings):
|
|
460
|
+
return {
|
|
461
|
+
"schema": "medical-notes-workbench.set-paths.v1",
|
|
462
|
+
"phase": "set-paths",
|
|
463
|
+
"status": "blocked",
|
|
464
|
+
"blocked_reason": "config_encoding.not_utf8",
|
|
465
|
+
"next_action": "Rodar repair-config-template --json para recriar config.toml em UTF-8 antes de alterar [paths].",
|
|
466
|
+
"required_inputs": ["utf8_config"],
|
|
467
|
+
"human_decision_required": False,
|
|
468
|
+
"config_path": str(config_path),
|
|
469
|
+
"wiki_dir": str(wiki_path),
|
|
470
|
+
"raw_dir": str(raw_path),
|
|
471
|
+
"config_encoding_warnings": encoding_warnings,
|
|
472
|
+
}
|
|
473
|
+
existing = _read_paths_section(config_path)
|
|
474
|
+
conflicts = _valid_existing_path_conflicts(existing, wiki_dir=wiki_path, raw_dir=raw_path)
|
|
475
|
+
if agent_repair and conflicts:
|
|
476
|
+
packet = _path_conflict_packet(conflicts, wiki_dir=wiki_path, raw_dir=raw_path, config_path=config_path)
|
|
477
|
+
packet_payload = _human_decision_packet_payload(packet)
|
|
478
|
+
return {
|
|
479
|
+
"schema": "medical-notes-workbench.set-paths.v1",
|
|
480
|
+
"phase": "set-paths",
|
|
481
|
+
"status": "blocked",
|
|
482
|
+
"blocked_reason": "path_conflict.requires_decision",
|
|
483
|
+
"next_action": "O TOML do app ja aponta para caminhos validos diferentes; confirmar qual par deve permanecer antes de sobrescrever.",
|
|
484
|
+
"required_inputs": ["human_decision"],
|
|
485
|
+
"human_decision_required": True,
|
|
486
|
+
"human_decision_packet": packet_payload,
|
|
487
|
+
"human_decision_packets": [packet_payload],
|
|
488
|
+
"config_path": str(config_path),
|
|
489
|
+
"wiki_dir": str(wiki_path),
|
|
490
|
+
"raw_dir": str(raw_path),
|
|
491
|
+
"conflicts": conflicts,
|
|
492
|
+
"config_encoding_warnings": encoding_warnings,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
_write_paths_config(config_path, wiki_dir=wiki_path, raw_dir=raw_path)
|
|
496
|
+
return {
|
|
497
|
+
"schema": "medical-notes-workbench.set-paths.v1",
|
|
498
|
+
"phase": "set-paths",
|
|
499
|
+
"status": "updated",
|
|
500
|
+
"blocked_reason": "",
|
|
501
|
+
"next_action": "",
|
|
502
|
+
"required_inputs": [],
|
|
503
|
+
"human_decision_required": False,
|
|
504
|
+
"config_path": str(config_path),
|
|
505
|
+
"wiki_dir": str(wiki_path),
|
|
506
|
+
"raw_dir": str(raw_path),
|
|
507
|
+
"config_encoding_warnings": encoding_warnings,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def repair_config_template(
|
|
512
|
+
*,
|
|
513
|
+
config: str | os.PathLike[str] | Path | None = None,
|
|
514
|
+
template: str | os.PathLike[str] | Path | None = None,
|
|
515
|
+
dry_run: bool = False,
|
|
516
|
+
) -> dict[str, Any]:
|
|
517
|
+
config_path = find_config(config) or default_config_path()
|
|
518
|
+
template_path = expand_path(template) if template else _infer_extension_root() / "config.example.toml"
|
|
519
|
+
warnings_before = config_encoding_warnings(config_path)
|
|
520
|
+
if not template_path.is_file():
|
|
521
|
+
return {
|
|
522
|
+
"schema": CONFIG_REPAIR_SCHEMA,
|
|
523
|
+
"phase": "repair-config-template",
|
|
524
|
+
"status": "blocked",
|
|
525
|
+
"blocked_reason": "config_template_missing",
|
|
526
|
+
"next_action": "Fornecer --template apontando para config.example.toml UTF-8.",
|
|
527
|
+
"required_inputs": ["template"],
|
|
528
|
+
"human_decision_required": False,
|
|
529
|
+
"config_path": str(config_path),
|
|
530
|
+
"template_path": str(template_path),
|
|
531
|
+
"warnings_before": warnings_before,
|
|
532
|
+
}
|
|
533
|
+
try:
|
|
534
|
+
template_text = template_path.read_text(encoding="utf-8")
|
|
535
|
+
except UnicodeDecodeError as exc:
|
|
536
|
+
return {
|
|
537
|
+
"schema": CONFIG_REPAIR_SCHEMA,
|
|
538
|
+
"phase": "repair-config-template",
|
|
539
|
+
"status": "blocked",
|
|
540
|
+
"blocked_reason": "config_template_not_utf8",
|
|
541
|
+
"next_action": "Substituir o template por uma copia UTF-8 sem BOM antes de reparar config.toml.",
|
|
542
|
+
"required_inputs": ["template"],
|
|
543
|
+
"human_decision_required": False,
|
|
544
|
+
"config_path": str(config_path),
|
|
545
|
+
"template_path": str(template_path),
|
|
546
|
+
"warnings_before": warnings_before,
|
|
547
|
+
"error": str(exc),
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
existing_paths = _read_paths_section_relaxed(config_path)
|
|
551
|
+
repaired_text = _replace_paths_section_values(template_text, existing_paths)
|
|
552
|
+
current_text = _read_text_relaxed(config_path)[0] if config_path.exists() else ""
|
|
553
|
+
changed = current_text != repaired_text
|
|
554
|
+
status = "planned" if dry_run else "updated" if changed else "unchanged"
|
|
555
|
+
if not dry_run and changed:
|
|
556
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
557
|
+
tmp_path = config_path.with_name(config_path.name + ".tmp")
|
|
558
|
+
tmp_path.write_text(repaired_text, encoding="utf-8")
|
|
559
|
+
os.replace(tmp_path, config_path)
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
"schema": CONFIG_REPAIR_SCHEMA,
|
|
563
|
+
"phase": "repair-config-template",
|
|
564
|
+
"status": status,
|
|
565
|
+
"blocked_reason": "",
|
|
566
|
+
"next_action": "",
|
|
567
|
+
"required_inputs": [],
|
|
568
|
+
"human_decision_required": False,
|
|
569
|
+
"config_path": str(config_path),
|
|
570
|
+
"template_path": str(template_path),
|
|
571
|
+
"changed": changed,
|
|
572
|
+
"dry_run": bool(dry_run),
|
|
573
|
+
"preserved_paths": existing_paths,
|
|
574
|
+
"warnings_before": warnings_before,
|
|
575
|
+
"warnings_after": [] if dry_run else config_encoding_warnings(config_path),
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def environment_preflight(
|
|
580
|
+
*,
|
|
581
|
+
extension_root: str | os.PathLike[str] | None = None,
|
|
582
|
+
state_dir: str | os.PathLike[str] | None = None,
|
|
583
|
+
sample_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None = None,
|
|
584
|
+
platform_name: str | None = None,
|
|
585
|
+
python_version: tuple[int, int, int] | None = None,
|
|
586
|
+
uv_path: str | None = None,
|
|
587
|
+
powershell_command: str | None = None,
|
|
588
|
+
require_uv: bool = True,
|
|
589
|
+
) -> dict[str, Any]:
|
|
590
|
+
"""Return a compact preflight for Python/uv/venv/path issues.
|
|
591
|
+
|
|
592
|
+
Parameters make Windows cases testable on non-Windows CI without shelling
|
|
593
|
+
out or touching the user's real PATH.
|
|
594
|
+
"""
|
|
595
|
+
state = expand_path(state_dir) if state_dir else user_state_dir()
|
|
596
|
+
extension = expand_path(extension_root) if extension_root else _infer_extension_root()
|
|
597
|
+
persistent_venv = state / ".venv"
|
|
598
|
+
bundle_venv = extension / ".venv" if extension else None
|
|
599
|
+
detected_platform = platform_name or platform.system()
|
|
600
|
+
is_windows = detected_platform.lower().startswith("win")
|
|
601
|
+
version = python_version or sys.version_info[:3]
|
|
602
|
+
uv = uv_path if uv_path is not None else shutil.which("uv")
|
|
603
|
+
configured_venv = os.environ.get("UV_PROJECT_ENVIRONMENT", "")
|
|
604
|
+
paths = [str(item) for item in (sample_paths or []) if str(item).strip()]
|
|
605
|
+
if configured_venv:
|
|
606
|
+
paths.append(configured_venv)
|
|
607
|
+
if extension:
|
|
608
|
+
paths.append(str(extension))
|
|
609
|
+
paths.append(str(state))
|
|
610
|
+
|
|
611
|
+
checks: list[dict[str, Any]] = []
|
|
612
|
+
warnings: list[str] = []
|
|
613
|
+
blockers: list[str] = []
|
|
614
|
+
|
|
615
|
+
def add_check(name: str, ok: bool, detail: str = "", *, warning: bool = False) -> None:
|
|
616
|
+
checks.append({"name": name, "ok": bool(ok), "detail": detail, "warning": bool(warning and ok is False)})
|
|
617
|
+
if ok:
|
|
618
|
+
return
|
|
619
|
+
(warnings if warning else blockers).append(name)
|
|
620
|
+
|
|
621
|
+
add_check(
|
|
622
|
+
"python_version",
|
|
623
|
+
tuple(version) >= (3, 11, 0),
|
|
624
|
+
".".join(str(part) for part in version),
|
|
625
|
+
)
|
|
626
|
+
add_check("uv_available", bool(uv), uv or "uv not found on PATH", warning=not require_uv)
|
|
627
|
+
|
|
628
|
+
if configured_venv:
|
|
629
|
+
normalized_configured = _normcase_path(expand_path(configured_venv))
|
|
630
|
+
normalized_expected = _normcase_path(persistent_venv)
|
|
631
|
+
add_check(
|
|
632
|
+
"uv_project_environment_persistent",
|
|
633
|
+
normalized_configured == normalized_expected,
|
|
634
|
+
f"UV_PROJECT_ENVIRONMENT={configured_venv}; expected={persistent_venv}",
|
|
635
|
+
warning=True,
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
add_check(
|
|
639
|
+
"uv_project_environment_set",
|
|
640
|
+
False,
|
|
641
|
+
f"set UV_PROJECT_ENVIRONMENT to {persistent_venv}",
|
|
642
|
+
warning=True,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
add_check(
|
|
646
|
+
"persistent_venv_exists",
|
|
647
|
+
persistent_venv.exists(),
|
|
648
|
+
str(persistent_venv),
|
|
649
|
+
warning=True,
|
|
650
|
+
)
|
|
651
|
+
if bundle_venv is not None:
|
|
652
|
+
add_check(
|
|
653
|
+
"bundle_venv_absent",
|
|
654
|
+
not bundle_venv.exists(),
|
|
655
|
+
str(bundle_venv),
|
|
656
|
+
warning=True,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if is_windows:
|
|
660
|
+
add_check(
|
|
661
|
+
"powershell_execution_policy_hint",
|
|
662
|
+
True,
|
|
663
|
+
"use -ExecutionPolicy Bypass with bundled setup/reset scripts",
|
|
664
|
+
)
|
|
665
|
+
if powershell_command:
|
|
666
|
+
add_check(
|
|
667
|
+
"powershell_command_quoted",
|
|
668
|
+
_powershell_command_looks_quoted(powershell_command),
|
|
669
|
+
powershell_command,
|
|
670
|
+
warning=True,
|
|
671
|
+
)
|
|
672
|
+
for item in paths:
|
|
673
|
+
if _path_needs_windows_quoting(item):
|
|
674
|
+
add_check("windows_path_with_spaces", False, item, warning=True)
|
|
675
|
+
break
|
|
676
|
+
for item in paths:
|
|
677
|
+
if "\r\n" in item:
|
|
678
|
+
add_check("crlf_in_path_or_command", False, "CRLF detected in path/command text", warning=True)
|
|
679
|
+
break
|
|
680
|
+
for item in paths:
|
|
681
|
+
if len(item) >= 240:
|
|
682
|
+
add_check("windows_long_path_risk", False, f"{len(item)} chars", warning=True)
|
|
683
|
+
break
|
|
684
|
+
|
|
685
|
+
status = "blocked" if blockers else "completed_with_warnings" if warnings else "completed"
|
|
686
|
+
next_action = ""
|
|
687
|
+
if status != "completed":
|
|
688
|
+
next_action = _environment_next_action(is_windows=is_windows)
|
|
689
|
+
return {
|
|
690
|
+
"schema": ENVIRONMENT_PREFLIGHT_SCHEMA,
|
|
691
|
+
"status": status,
|
|
692
|
+
"blocked_reason": ENVIRONMENT_BLOCKER_CODE if blockers else "",
|
|
693
|
+
"next_action": next_action,
|
|
694
|
+
"required_inputs": ["python", "uv", "persistent_venv", "wiki_dir"],
|
|
695
|
+
"platform": detected_platform,
|
|
696
|
+
"python": ".".join(str(part) for part in version),
|
|
697
|
+
"uv_path": uv or "",
|
|
698
|
+
"state_dir": str(state),
|
|
699
|
+
"persistent_venv": str(persistent_venv),
|
|
700
|
+
"extension_root": str(extension) if extension else "",
|
|
701
|
+
"checks": checks,
|
|
702
|
+
"warnings": warnings,
|
|
703
|
+
"blockers": blockers,
|
|
704
|
+
"setup_command": "/mednotes:setup",
|
|
705
|
+
"reset_command": "scripts\\bootstrap_windows_python_uv.ps1" if is_windows else "uv sync",
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _infer_extension_root() -> Path:
|
|
710
|
+
return Path(__file__).resolve().parents[2]
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _normcase_path(path_value: Path) -> str:
|
|
714
|
+
return os.path.normcase(str(path_value.expanduser().resolve(strict=False)))
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _path_needs_windows_quoting(value: str) -> bool:
|
|
718
|
+
text = str(value or "")
|
|
719
|
+
return bool(re.search(r"\s", text) and re.match(r"^[A-Za-z]:[\\/]", text))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _powershell_command_looks_quoted(command: str) -> bool:
|
|
723
|
+
text = str(command or "")
|
|
724
|
+
if not text.strip():
|
|
725
|
+
return True
|
|
726
|
+
windows_paths = re.findall(r"[A-Za-z]:[\\/][^;&|]+", text)
|
|
727
|
+
for value in windows_paths:
|
|
728
|
+
if " " in value and f'"{value}"' not in text and f"'{value}'" not in text:
|
|
729
|
+
return False
|
|
730
|
+
return True
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _environment_next_action(*, is_windows: bool) -> str:
|
|
734
|
+
if is_windows:
|
|
735
|
+
return (
|
|
736
|
+
"Rodar /mednotes:setup. Se persistir no Windows, executar "
|
|
737
|
+
"scripts\\bootstrap_windows_python_uv.ps1; como fallback, "
|
|
738
|
+
"scripts\\reset_windows_python_uv.ps1 -FullReset."
|
|
739
|
+
)
|
|
740
|
+
return (
|
|
741
|
+
"Rodar /mednotes:setup, configurar UV_PROJECT_ENVIRONMENT para "
|
|
742
|
+
"~/.mednotes/.venv e repetir uv sync antes do workflow."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _candidate(
|
|
747
|
+
path: Path,
|
|
748
|
+
source: str,
|
|
749
|
+
reason: str,
|
|
750
|
+
compat_warning: str = "",
|
|
751
|
+
*,
|
|
752
|
+
raw_dir: Path | None = None,
|
|
753
|
+
confidence: str = "",
|
|
754
|
+
) -> PathCandidate:
|
|
755
|
+
return PathCandidate(
|
|
756
|
+
path=path,
|
|
757
|
+
source=source,
|
|
758
|
+
exists=path.exists(),
|
|
759
|
+
is_dir=path.is_dir(),
|
|
760
|
+
reason=reason,
|
|
761
|
+
compat_warning=compat_warning,
|
|
762
|
+
raw_dir=raw_dir,
|
|
763
|
+
confidence=confidence,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _env_wiki_candidate() -> PathCandidate | None:
|
|
768
|
+
env_value = os.getenv("MED_WIKI_DIR")
|
|
769
|
+
if not env_value:
|
|
770
|
+
return None
|
|
771
|
+
return _candidate(
|
|
772
|
+
expand_path(env_value).resolve(strict=False),
|
|
773
|
+
"env:MED_WIKI_DIR",
|
|
774
|
+
"temporary environment override",
|
|
775
|
+
"MED_WIKI_DIR e override temporario; persista o caminho em config.toml [paths].wiki_dir.",
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _config_wiki_candidate(config_path: Path | None) -> PathCandidate | None:
|
|
780
|
+
cfg = read_toml(config_path)
|
|
781
|
+
paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
|
|
782
|
+
value = paths.get("wiki_dir")
|
|
783
|
+
if not value:
|
|
784
|
+
return None
|
|
785
|
+
return _candidate(
|
|
786
|
+
expand_path(str(value)).resolve(strict=False),
|
|
787
|
+
"config:[paths].wiki_dir",
|
|
788
|
+
f"{config_path or default_config_path()} [paths].wiki_dir",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _read_paths_section(config_path: Path | None) -> dict[str, str]:
|
|
793
|
+
cfg = read_toml(config_path)
|
|
794
|
+
paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
|
|
795
|
+
return {
|
|
796
|
+
key: str(value).strip()
|
|
797
|
+
for key, value in paths.items()
|
|
798
|
+
if key in {"wiki_dir", "raw_dir"} and isinstance(value, str) and value.strip()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _read_paths_section_relaxed(config_path: Path | None) -> dict[str, str]:
|
|
803
|
+
if not config_path or not config_path.exists() or tomllib is None:
|
|
804
|
+
return {}
|
|
805
|
+
text, _encoding = _read_text_relaxed(config_path)
|
|
806
|
+
try:
|
|
807
|
+
data = _loads_toml_with_windows_path_fallback(text)
|
|
808
|
+
except tomllib.TOMLDecodeError:
|
|
809
|
+
return {}
|
|
810
|
+
paths = data.get("paths", {}) if isinstance(data.get("paths"), dict) else {}
|
|
811
|
+
return {
|
|
812
|
+
key: str(value).strip()
|
|
813
|
+
for key, value in paths.items()
|
|
814
|
+
if key in {"wiki_dir", "raw_dir", "catalog_path", "vocabulary_db_path"}
|
|
815
|
+
and isinstance(value, str)
|
|
816
|
+
and value.strip()
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _read_text_relaxed(path: Path) -> tuple[str, str]:
|
|
821
|
+
raw = path.read_bytes()
|
|
822
|
+
for encoding in ("utf-8", "utf-8-sig", "utf-16"):
|
|
823
|
+
try:
|
|
824
|
+
return raw.decode(encoding), encoding
|
|
825
|
+
except UnicodeDecodeError:
|
|
826
|
+
continue
|
|
827
|
+
return raw.decode("utf-8", errors="replace"), "utf-8-replace"
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _valid_existing_path_conflicts(
|
|
831
|
+
existing: dict[str, str],
|
|
832
|
+
*,
|
|
833
|
+
wiki_dir: Path,
|
|
834
|
+
raw_dir: Path,
|
|
835
|
+
) -> list[dict[str, str]]:
|
|
836
|
+
conflicts: list[dict[str, str]] = []
|
|
837
|
+
desired = {"wiki_dir": wiki_dir, "raw_dir": raw_dir}
|
|
838
|
+
for field, desired_path in desired.items():
|
|
839
|
+
value = existing.get(field)
|
|
840
|
+
if not value:
|
|
841
|
+
continue
|
|
842
|
+
current = expand_path(value).resolve(strict=False)
|
|
843
|
+
if current == desired_path or not current.exists() or not current.is_dir():
|
|
844
|
+
continue
|
|
845
|
+
conflicts.append(
|
|
846
|
+
{
|
|
847
|
+
"field": field,
|
|
848
|
+
"current": str(current),
|
|
849
|
+
"proposed": str(desired_path),
|
|
850
|
+
}
|
|
851
|
+
)
|
|
852
|
+
return conflicts
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _path_conflict_packet(
|
|
856
|
+
conflicts: list[dict[str, str]],
|
|
857
|
+
*,
|
|
858
|
+
wiki_dir: Path,
|
|
859
|
+
raw_dir: Path,
|
|
860
|
+
config_path: Path,
|
|
861
|
+
) -> HumanDecisionPacket:
|
|
862
|
+
resume_action = "Repetir set-paths sem --agent-repair ou informar explicitamente a escolha humana."
|
|
863
|
+
packet = _path_human_decision_packet(
|
|
864
|
+
kind="path_conflict_choice",
|
|
865
|
+
phase="set-paths",
|
|
866
|
+
reason_code="path_conflict.requires_decision",
|
|
867
|
+
question="O TOML do app ja tem caminhos validos. Quais caminhos devem ser mantidos?",
|
|
868
|
+
developer_summary="Dois pares de paths locais validos competem; sobrescrever sem escolha pode apontar a Wiki errada.",
|
|
869
|
+
resume_action=resume_action,
|
|
870
|
+
options=[
|
|
871
|
+
{
|
|
872
|
+
"id": "keep_existing",
|
|
873
|
+
"label": "Manter TOML atual",
|
|
874
|
+
"value": str(config_path),
|
|
875
|
+
"description": "Caminhos existentes no TOML tambem sao diretorios validos.",
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
"id": "use_proposed",
|
|
879
|
+
"label": "Usar caminhos propostos",
|
|
880
|
+
"value": f"wiki_dir={wiki_dir}; raw_dir={raw_dir}",
|
|
881
|
+
"description": "Caminhos propostos foram validados localmente.",
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
evidence=[
|
|
885
|
+
DecisionEvidence(
|
|
886
|
+
summary="Config atual e proposta de agente apontam para diretorios validos diferentes.",
|
|
887
|
+
technical_code="path_conflict.requires_decision",
|
|
888
|
+
source="mednotes.platform.paths",
|
|
889
|
+
candidates=[{"conflicts": conflicts}],
|
|
890
|
+
risk="wrong_vault_mutation",
|
|
891
|
+
)
|
|
892
|
+
],
|
|
893
|
+
)
|
|
894
|
+
return packet.model_copy(update={"context": {"conflicts": conflicts, "config_path": str(config_path)}})
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _write_paths_config(config_path: Path, *, wiki_dir: Path, raw_dir: Path | None) -> None:
|
|
898
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
899
|
+
old_text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
900
|
+
new_text = _replace_paths_section(old_text, wiki_dir=wiki_dir, raw_dir=raw_dir)
|
|
901
|
+
tmp_path = config_path.with_name(config_path.name + ".tmp")
|
|
902
|
+
tmp_path.write_text(new_text, encoding="utf-8")
|
|
903
|
+
os.replace(tmp_path, config_path)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _replace_paths_section(text: str, *, wiki_dir: Path, raw_dir: Path | None) -> str:
|
|
907
|
+
values = {"wiki_dir": wiki_dir.as_posix()}
|
|
908
|
+
if raw_dir is not None:
|
|
909
|
+
values["raw_dir"] = raw_dir.as_posix()
|
|
910
|
+
return _replace_paths_section_values(text, values)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _replace_paths_section_values(text: str, values: dict[str, str]) -> str:
|
|
914
|
+
lines = text.splitlines()
|
|
915
|
+
section_start: int | None = None
|
|
916
|
+
section_end = len(lines)
|
|
917
|
+
for index, line in enumerate(lines):
|
|
918
|
+
if line.strip() == "[paths]":
|
|
919
|
+
section_start = index
|
|
920
|
+
for next_index in range(index + 1, len(lines)):
|
|
921
|
+
stripped = lines[next_index].strip()
|
|
922
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
923
|
+
section_end = next_index
|
|
924
|
+
break
|
|
925
|
+
break
|
|
926
|
+
|
|
927
|
+
field_order = ("wiki_dir", "raw_dir", "catalog_path", "vocabulary_db_path")
|
|
928
|
+
path_lines = [
|
|
929
|
+
f"{field} = {json.dumps(str(values[field]), ensure_ascii=False)}"
|
|
930
|
+
for field in field_order
|
|
931
|
+
if str(values.get(field) or "").strip()
|
|
932
|
+
]
|
|
933
|
+
if not path_lines:
|
|
934
|
+
path_lines = ['wiki_dir = ""', 'raw_dir = ""']
|
|
935
|
+
|
|
936
|
+
if section_start is None:
|
|
937
|
+
prefix = lines + ([""] if lines else [])
|
|
938
|
+
new_lines = [*prefix, "[paths]", *path_lines]
|
|
939
|
+
else:
|
|
940
|
+
remaining = [
|
|
941
|
+
line
|
|
942
|
+
for line in lines[section_start + 1 : section_end]
|
|
943
|
+
if not re.match(r"^\s*(wiki_dir|raw_dir|catalog_path|vocabulary_db_path)\s*=", line)
|
|
944
|
+
]
|
|
945
|
+
new_lines = [
|
|
946
|
+
*lines[: section_start + 1],
|
|
947
|
+
*path_lines,
|
|
948
|
+
*remaining,
|
|
949
|
+
*lines[section_end:],
|
|
950
|
+
]
|
|
951
|
+
return "\n".join(new_lines).rstrip() + "\n"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _contextual_candidates(
|
|
955
|
+
*,
|
|
956
|
+
start: Path | None,
|
|
957
|
+
context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
|
|
958
|
+
) -> list[PathCandidate]:
|
|
959
|
+
raw_paths: list[Path] = []
|
|
960
|
+
if context_paths:
|
|
961
|
+
raw_paths.extend(expand_path(item) for item in context_paths)
|
|
962
|
+
raw_paths.append(start or Path.cwd())
|
|
963
|
+
|
|
964
|
+
candidates: list[PathCandidate] = []
|
|
965
|
+
for raw in raw_paths:
|
|
966
|
+
path = raw.resolve() if raw.exists() else raw.expanduser().resolve(strict=False)
|
|
967
|
+
scan_start = path if path.is_dir() else path.parent
|
|
968
|
+
for current in (scan_start, *scan_start.parents):
|
|
969
|
+
if _looks_like_wiki_root(current):
|
|
970
|
+
candidates.append(_candidate(current, "context", "nearest plausible Wiki root"))
|
|
971
|
+
break
|
|
972
|
+
return _distinct_candidates(candidates)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _looks_like_wiki_root(path: Path) -> bool:
|
|
976
|
+
if not path.exists() or not path.is_dir():
|
|
977
|
+
return False
|
|
978
|
+
if any((path / dirname).is_dir() for dirname in _WIKI_DIR_PROBE_TOP_LEVEL_HINTS):
|
|
979
|
+
return True
|
|
980
|
+
if (path / ".obsidian").is_dir() and any(item.suffix.lower() == ".md" for item in path.glob("*.md")):
|
|
981
|
+
return True
|
|
982
|
+
if (path / ".obsidian").is_dir() and any((path / dirname).is_dir() for dirname in _WIKI_DIR_PROBE_TOP_LEVEL_HINTS):
|
|
983
|
+
return True
|
|
984
|
+
return False
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _distinct_candidates(candidates: list[PathCandidate]) -> list[PathCandidate]:
|
|
988
|
+
by_path: dict[str, PathCandidate] = {}
|
|
989
|
+
for candidate in candidates:
|
|
990
|
+
key = os.path.normcase(str(candidate.path.resolve() if candidate.path.exists() else candidate.path))
|
|
991
|
+
by_path.setdefault(key, candidate)
|
|
992
|
+
return list(by_path.values())
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _blocked(
|
|
996
|
+
reason: str,
|
|
997
|
+
action: str,
|
|
998
|
+
memory_path: Path,
|
|
999
|
+
config_path: Path | None,
|
|
1000
|
+
*,
|
|
1001
|
+
candidates: tuple[PathCandidate, ...],
|
|
1002
|
+
extra_next_action: str = "",
|
|
1003
|
+
compat_warnings: tuple[str, ...] = (),
|
|
1004
|
+
human_decision_packet: HumanDecisionPacket | None = None,
|
|
1005
|
+
) -> WikiPathResolution:
|
|
1006
|
+
next_action = action if not extra_next_action else f"{action} Detalhe: {extra_next_action}"
|
|
1007
|
+
return WikiPathResolution(
|
|
1008
|
+
path=None,
|
|
1009
|
+
source="",
|
|
1010
|
+
memory_path=memory_path,
|
|
1011
|
+
config_path=config_path,
|
|
1012
|
+
candidates=candidates,
|
|
1013
|
+
compat_warnings=compat_warnings,
|
|
1014
|
+
blocked_reason=reason,
|
|
1015
|
+
next_action=next_action,
|
|
1016
|
+
human_decision_packet=human_decision_packet,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _maybe_gemini_path_probe(
|
|
1021
|
+
blocked: WikiPathResolution,
|
|
1022
|
+
*,
|
|
1023
|
+
enabled: bool,
|
|
1024
|
+
start: Path | None,
|
|
1025
|
+
context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
|
|
1026
|
+
) -> WikiPathResolution:
|
|
1027
|
+
if not enabled or not _gemini_path_probe_enabled_by_env():
|
|
1028
|
+
return blocked
|
|
1029
|
+
|
|
1030
|
+
binary = _gemini_binary()
|
|
1031
|
+
if binary is None:
|
|
1032
|
+
return _blocked_with_probe_warning(blocked, "Gemini CLI nao encontrado para sondagem de caminhos.")
|
|
1033
|
+
|
|
1034
|
+
context = _gemini_probe_context(
|
|
1035
|
+
start=start,
|
|
1036
|
+
context_paths=context_paths,
|
|
1037
|
+
memory_path=blocked.memory_path,
|
|
1038
|
+
)
|
|
1039
|
+
if not context.strip():
|
|
1040
|
+
context = "Nenhum arquivo de contexto local foi encontrado."
|
|
1041
|
+
|
|
1042
|
+
retry_detail = ""
|
|
1043
|
+
probe_warnings: list[str] = []
|
|
1044
|
+
for attempt in range(2):
|
|
1045
|
+
prompt = _gemini_probe_prompt(blocked, retry_detail=retry_detail, attempt=attempt)
|
|
1046
|
+
result = _run_gemini_probe(binary, prompt, context)
|
|
1047
|
+
if result.get("error"):
|
|
1048
|
+
retry_detail = str(result["error"])
|
|
1049
|
+
probe_warnings.append(retry_detail)
|
|
1050
|
+
continue
|
|
1051
|
+
|
|
1052
|
+
payload = _extract_json_object(str(result.get("stdout", "")))
|
|
1053
|
+
if payload is None:
|
|
1054
|
+
retry_detail = "A resposta do Gemini nao continha um objeto JSON parseavel."
|
|
1055
|
+
probe_warnings.append(retry_detail)
|
|
1056
|
+
continue
|
|
1057
|
+
|
|
1058
|
+
candidates, invalid_reasons = _validated_probe_candidates(payload)
|
|
1059
|
+
if len(candidates) == 1:
|
|
1060
|
+
candidate = candidates[0]
|
|
1061
|
+
target_config = blocked.config_path or default_config_path()
|
|
1062
|
+
_write_paths_config(
|
|
1063
|
+
target_config,
|
|
1064
|
+
wiki_dir=candidate.path,
|
|
1065
|
+
raw_dir=candidate.raw_dir,
|
|
1066
|
+
)
|
|
1067
|
+
return WikiPathResolution(
|
|
1068
|
+
path=candidate.path,
|
|
1069
|
+
source="gemini_probe",
|
|
1070
|
+
memory_path=blocked.memory_path,
|
|
1071
|
+
config_path=target_config,
|
|
1072
|
+
candidates=(candidate,),
|
|
1073
|
+
compat_warnings=(
|
|
1074
|
+
f"Caminhos validados via Gemini CLI e persistidos em {target_config}.",
|
|
1075
|
+
),
|
|
1076
|
+
)
|
|
1077
|
+
if len(candidates) > 1:
|
|
1078
|
+
target_config = blocked.config_path or default_config_path()
|
|
1079
|
+
return _blocked(
|
|
1080
|
+
"ambiguous_wiki_dir",
|
|
1081
|
+
f"Escolher um unico wiki_dir e registrar em {target_config} [paths].wiki_dir.",
|
|
1082
|
+
blocked.memory_path,
|
|
1083
|
+
target_config,
|
|
1084
|
+
candidates=tuple(candidates),
|
|
1085
|
+
compat_warnings=tuple(probe_warnings),
|
|
1086
|
+
human_decision_packet=_wiki_path_choice_packet(tuple(candidates), target_config),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
retry_detail = "; ".join(invalid_reasons) or "Gemini nao retornou candidatos validos."
|
|
1090
|
+
probe_warnings.append(retry_detail)
|
|
1091
|
+
|
|
1092
|
+
return _blocked_with_probe_warning(blocked, "Sondagem Gemini sem caminho valido: " + "; ".join(probe_warnings))
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _gemini_path_probe_enabled_by_env() -> bool:
|
|
1096
|
+
if os.getenv("PYTEST_CURRENT_TEST") and not os.getenv("MEDNOTES_GEMINI_BINARY"):
|
|
1097
|
+
return False
|
|
1098
|
+
value = os.getenv("MEDNOTES_GEMINI_PATH_PROBE", "").strip().lower()
|
|
1099
|
+
return value not in {"0", "false", "no", "off"}
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _gemini_binary() -> Path | None:
|
|
1103
|
+
configured = os.getenv("MEDNOTES_GEMINI_BINARY")
|
|
1104
|
+
if configured:
|
|
1105
|
+
configured_path = expand_path(configured)
|
|
1106
|
+
if configured_path.exists():
|
|
1107
|
+
return configured_path
|
|
1108
|
+
found = shutil.which(configured)
|
|
1109
|
+
return Path(found) if found else None
|
|
1110
|
+
found = shutil.which("gemini")
|
|
1111
|
+
return Path(found) if found else None
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _gemini_probe_prompt(blocked: WikiPathResolution, *, retry_detail: str, attempt: int) -> str:
|
|
1115
|
+
retry = ""
|
|
1116
|
+
if retry_detail:
|
|
1117
|
+
retry = (
|
|
1118
|
+
"\n\nTentativa anterior falhou na validacao local. "
|
|
1119
|
+
f"Problema: {retry_detail}. Retorne outro candidato se houver."
|
|
1120
|
+
)
|
|
1121
|
+
return (
|
|
1122
|
+
"Voce esta ajudando o Medical Notes Workbench a descobrir caminhos locais em tempo de execucao.\n"
|
|
1123
|
+
"Use apenas os arquivos de contexto fornecidos abaixo. Eles podem incluir GEMINI.md e arquivos "
|
|
1124
|
+
"Markdown referenciados por ele.\n"
|
|
1125
|
+
"Responda somente com JSON valido, sem markdown, no schema "
|
|
1126
|
+
f"{GEMINI_PATH_PROBE_SCHEMA}.\n"
|
|
1127
|
+
"Formato aceito: {\"wiki_dir\":\"/abs/Wiki_Medicina\",\"raw_dir\":\"/abs/Chats_Raw\","
|
|
1128
|
+
"\"confidence\":\"high|medium|low\",\"evidence\":\"arquivo/trecho curto\",\"source\":\"arquivo-de-contexto.md\"}.\n"
|
|
1129
|
+
"Se houver mais de uma possibilidade, responda {\"candidates\":[...]} com objetos no mesmo formato.\n"
|
|
1130
|
+
"Caminhos devem ser absolutos. Se nao houver evidencia suficiente, use candidates=[].\n"
|
|
1131
|
+
f"Bloqueio atual: {blocked.blocked_reason}. Acao esperada: {blocked.next_action}.\n"
|
|
1132
|
+
f"Tentativa: {attempt + 1}."
|
|
1133
|
+
f"{retry}\n\n"
|
|
1134
|
+
"CONTEXTO LOCAL:\n"
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _run_gemini_probe(binary: Path, prompt: str, context: str) -> dict[str, object]:
|
|
1139
|
+
timeout = _probe_timeout_seconds()
|
|
1140
|
+
try:
|
|
1141
|
+
asyncio.get_running_loop()
|
|
1142
|
+
except RuntimeError:
|
|
1143
|
+
pass
|
|
1144
|
+
else:
|
|
1145
|
+
return {"error": "Sondagem Gemini requer chamada síncrona fora de um event loop ativo."}
|
|
1146
|
+
try:
|
|
1147
|
+
return asyncio.run(_run_gemini_probe_async(binary, prompt + context, context, timeout))
|
|
1148
|
+
except RuntimeError as exc:
|
|
1149
|
+
return {"error": f"Falha ao iniciar sondagem async do Gemini: {exc}"}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
async def _run_gemini_probe_async(binary: Path, prompt: str, context: str, timeout: float) -> dict[str, object]:
|
|
1153
|
+
if _gemini_probe_needs_prompt_file(binary, prompt):
|
|
1154
|
+
with tempfile.TemporaryDirectory(prefix="mednotes-path-probe-") as tmp:
|
|
1155
|
+
prompt_path = Path(tmp) / "prompt.md"
|
|
1156
|
+
prompt_path.write_text(prompt, encoding="utf-8")
|
|
1157
|
+
cmd = _gemini_probe_subprocess_command(
|
|
1158
|
+
[
|
|
1159
|
+
str(binary),
|
|
1160
|
+
"--include-directories",
|
|
1161
|
+
str(prompt_path.parent),
|
|
1162
|
+
"-p",
|
|
1163
|
+
f"@{prompt_path}",
|
|
1164
|
+
"--approval-mode",
|
|
1165
|
+
"plan",
|
|
1166
|
+
]
|
|
1167
|
+
)
|
|
1168
|
+
return await _communicate_gemini_probe(cmd, context, timeout)
|
|
1169
|
+
|
|
1170
|
+
cmd = _gemini_probe_subprocess_command(
|
|
1171
|
+
[
|
|
1172
|
+
str(binary),
|
|
1173
|
+
"-p",
|
|
1174
|
+
prompt,
|
|
1175
|
+
"--approval-mode",
|
|
1176
|
+
"plan",
|
|
1177
|
+
]
|
|
1178
|
+
)
|
|
1179
|
+
return await _communicate_gemini_probe(cmd, context, timeout)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
async def _communicate_gemini_probe(cmd: list[str], context: str, timeout: float) -> dict[str, object]:
|
|
1183
|
+
try:
|
|
1184
|
+
process = await asyncio.create_subprocess_exec(
|
|
1185
|
+
*cmd,
|
|
1186
|
+
stdin=asyncio.subprocess.PIPE,
|
|
1187
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1188
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1189
|
+
)
|
|
1190
|
+
try:
|
|
1191
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(context.encode("utf-8")), timeout=timeout)
|
|
1192
|
+
except TimeoutError:
|
|
1193
|
+
process.kill()
|
|
1194
|
+
await process.communicate()
|
|
1195
|
+
return {"error": f"Gemini CLI excedeu {timeout:g}s na sondagem de caminhos."}
|
|
1196
|
+
except TimeoutError:
|
|
1197
|
+
return {"error": f"Gemini CLI excedeu {timeout:g}s na sondagem de caminhos."}
|
|
1198
|
+
except OSError as exc:
|
|
1199
|
+
return {"error": f"Gemini CLI nao pode ser executado: {exc}"}
|
|
1200
|
+
if process.returncode != 0:
|
|
1201
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
|
1202
|
+
return {"error": f"Gemini CLI retornou codigo {process.returncode}: {stderr_text[:500]}"}
|
|
1203
|
+
return {
|
|
1204
|
+
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
1205
|
+
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _gemini_probe_needs_prompt_file(binary: Path, prompt: str) -> bool:
|
|
1210
|
+
if os.name != "nt":
|
|
1211
|
+
return False
|
|
1212
|
+
suffix = Path(str(binary)).suffix.lower()
|
|
1213
|
+
return suffix in {".cmd", ".bat"} or len(prompt) >= _WINDOWS_PROMPT_FILE_THRESHOLD
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _gemini_probe_subprocess_command(cmd: list[str]) -> list[str]:
|
|
1217
|
+
if not cmd:
|
|
1218
|
+
return cmd
|
|
1219
|
+
suffix = Path(cmd[0]).suffix.lower()
|
|
1220
|
+
if os.name == "nt" and suffix in {".cmd", ".bat"}:
|
|
1221
|
+
return [os.environ.get("COMSPEC") or "cmd.exe", "/d", "/s", "/c", *cmd]
|
|
1222
|
+
return cmd
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _probe_timeout_seconds() -> float:
|
|
1226
|
+
raw = os.getenv("MEDNOTES_GEMINI_PATH_PROBE_TIMEOUT", "20").strip()
|
|
1227
|
+
try:
|
|
1228
|
+
value = float(raw)
|
|
1229
|
+
except ValueError:
|
|
1230
|
+
return 20.0
|
|
1231
|
+
return max(1.0, min(value, 120.0))
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _extract_json_object(text: str) -> dict[str, Any] | None:
|
|
1235
|
+
stripped = text.strip()
|
|
1236
|
+
if not stripped:
|
|
1237
|
+
return None
|
|
1238
|
+
try:
|
|
1239
|
+
parsed = json.loads(stripped)
|
|
1240
|
+
except json.JSONDecodeError:
|
|
1241
|
+
start = stripped.find("{")
|
|
1242
|
+
end = stripped.rfind("}")
|
|
1243
|
+
if start < 0 or end <= start:
|
|
1244
|
+
return None
|
|
1245
|
+
try:
|
|
1246
|
+
parsed = json.loads(stripped[start : end + 1])
|
|
1247
|
+
except json.JSONDecodeError:
|
|
1248
|
+
return None
|
|
1249
|
+
return parsed if isinstance(parsed, dict) else None
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _validated_probe_candidates(payload: dict[str, Any]) -> tuple[list[PathCandidate], list[str]]:
|
|
1253
|
+
raw_candidates: list[Any]
|
|
1254
|
+
if isinstance(payload.get("candidates"), list):
|
|
1255
|
+
raw_candidates = payload["candidates"]
|
|
1256
|
+
elif payload.get("wiki_dir") or payload.get("path"):
|
|
1257
|
+
raw_candidates = [payload]
|
|
1258
|
+
else:
|
|
1259
|
+
raw_candidates = []
|
|
1260
|
+
|
|
1261
|
+
candidates: list[PathCandidate] = []
|
|
1262
|
+
invalid_reasons: list[str] = []
|
|
1263
|
+
for index, raw in enumerate(raw_candidates, start=1):
|
|
1264
|
+
if isinstance(raw, str):
|
|
1265
|
+
raw = {"wiki_dir": raw}
|
|
1266
|
+
if not isinstance(raw, dict):
|
|
1267
|
+
invalid_reasons.append(f"candidato {index} nao e objeto JSON")
|
|
1268
|
+
continue
|
|
1269
|
+
|
|
1270
|
+
wiki_value = str(raw.get("wiki_dir") or raw.get("path") or "").strip()
|
|
1271
|
+
if not wiki_value:
|
|
1272
|
+
invalid_reasons.append(f"candidato {index} sem wiki_dir")
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
confidence = str(raw.get("confidence") or "medium").strip().lower()
|
|
1276
|
+
if confidence == "low":
|
|
1277
|
+
invalid_reasons.append(f"{wiki_value}: confidence low")
|
|
1278
|
+
continue
|
|
1279
|
+
|
|
1280
|
+
wiki_path = expand_path(wiki_value).resolve(strict=False)
|
|
1281
|
+
if not wiki_path.exists() or not wiki_path.is_dir():
|
|
1282
|
+
invalid_reasons.append(f"{wiki_path}: nao existe ou nao e diretorio")
|
|
1283
|
+
continue
|
|
1284
|
+
if not _looks_like_wiki_root(wiki_path):
|
|
1285
|
+
invalid_reasons.append(f"{wiki_path}: nao parece raiz da Wiki_Medicina")
|
|
1286
|
+
continue
|
|
1287
|
+
|
|
1288
|
+
raw_path = _validated_optional_raw_dir(raw.get("raw_dir"))
|
|
1289
|
+
if raw.get("raw_dir") and raw_path is None:
|
|
1290
|
+
invalid_reasons.append(f"{raw.get('raw_dir')}: raw_dir nao existe ou nao e diretorio")
|
|
1291
|
+
continue
|
|
1292
|
+
|
|
1293
|
+
evidence = str(raw.get("evidence") or raw.get("source") or "Gemini CLI path probe").strip()
|
|
1294
|
+
candidates.append(
|
|
1295
|
+
_candidate(
|
|
1296
|
+
wiki_path,
|
|
1297
|
+
"gemini_probe",
|
|
1298
|
+
evidence,
|
|
1299
|
+
raw_dir=raw_path,
|
|
1300
|
+
confidence=confidence,
|
|
1301
|
+
)
|
|
1302
|
+
)
|
|
1303
|
+
return _distinct_candidates(candidates), invalid_reasons
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _validated_optional_raw_dir(value: object) -> Path | None:
|
|
1307
|
+
if not isinstance(value, str) or not value.strip():
|
|
1308
|
+
return None
|
|
1309
|
+
path = expand_path(value.strip()).resolve(strict=False)
|
|
1310
|
+
return path if path.exists() and path.is_dir() else None
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _write_persistent_paths(
|
|
1314
|
+
memory_path: Path,
|
|
1315
|
+
*,
|
|
1316
|
+
wiki_dir: Path,
|
|
1317
|
+
raw_dir: Path | None,
|
|
1318
|
+
evidence: str,
|
|
1319
|
+
) -> None:
|
|
1320
|
+
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1321
|
+
lines = [
|
|
1322
|
+
f"```toml {PATHS_SCHEMA}",
|
|
1323
|
+
"# Atualizado automaticamente depois de validar a sondagem Gemini CLI.",
|
|
1324
|
+
f"# Evidencia: {_toml_comment(evidence)}",
|
|
1325
|
+
"[paths]",
|
|
1326
|
+
f"wiki_dir = {json.dumps(wiki_dir.as_posix(), ensure_ascii=False)}",
|
|
1327
|
+
]
|
|
1328
|
+
if raw_dir is not None:
|
|
1329
|
+
lines.append(f"raw_dir = {json.dumps(raw_dir.as_posix(), ensure_ascii=False)}")
|
|
1330
|
+
lines.append("```")
|
|
1331
|
+
block = "\n".join(lines) + "\n"
|
|
1332
|
+
|
|
1333
|
+
old_text = memory_path.read_text(encoding="utf-8") if memory_path.exists() else ""
|
|
1334
|
+
if _PATHS_BLOCK_RE.search(old_text):
|
|
1335
|
+
new_text = _PATHS_BLOCK_RE.sub(block.rstrip("\n"), old_text, count=1)
|
|
1336
|
+
if not new_text.endswith("\n"):
|
|
1337
|
+
new_text += "\n"
|
|
1338
|
+
else:
|
|
1339
|
+
prefix = old_text.rstrip()
|
|
1340
|
+
new_text = (prefix + "\n\n" if prefix else "# Medical Notes Workbench local memory\n\n") + block
|
|
1341
|
+
memory_path.write_text(new_text, encoding="utf-8")
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _toml_comment(value: str) -> str:
|
|
1345
|
+
return str(value).replace("\r", " ").replace("\n", " ")[:240]
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _blocked_with_probe_warning(blocked: WikiPathResolution, warning: str) -> WikiPathResolution:
|
|
1349
|
+
return WikiPathResolution(
|
|
1350
|
+
path=None,
|
|
1351
|
+
source=blocked.source,
|
|
1352
|
+
memory_path=blocked.memory_path,
|
|
1353
|
+
config_path=blocked.config_path,
|
|
1354
|
+
candidates=blocked.candidates,
|
|
1355
|
+
compat_warnings=(*blocked.compat_warnings, warning),
|
|
1356
|
+
blocked_reason=blocked.blocked_reason,
|
|
1357
|
+
next_action=blocked.next_action,
|
|
1358
|
+
required_inputs=blocked.required_inputs,
|
|
1359
|
+
human_decision_packet=blocked.human_decision_packet,
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _wiki_path_choice_packet(candidates: tuple[PathCandidate, ...], config_path: Path) -> HumanDecisionPacket | None:
|
|
1364
|
+
distinct = _distinct_candidates(list(candidates))
|
|
1365
|
+
if not distinct:
|
|
1366
|
+
return None
|
|
1367
|
+
options: list[dict[str, object]] = []
|
|
1368
|
+
for index, candidate in enumerate(distinct, start=1):
|
|
1369
|
+
label = candidate.path.name or str(candidate.path)
|
|
1370
|
+
options.append(
|
|
1371
|
+
{
|
|
1372
|
+
"id": f"wiki_path_{index}",
|
|
1373
|
+
"label": label,
|
|
1374
|
+
"value": str(candidate.path),
|
|
1375
|
+
"description": f"{candidate.source}: {candidate.reason}",
|
|
1376
|
+
}
|
|
1377
|
+
)
|
|
1378
|
+
packet = _path_human_decision_packet(
|
|
1379
|
+
kind="wiki_path_choice",
|
|
1380
|
+
phase="resolve_wiki_dir",
|
|
1381
|
+
reason_code="ambiguous_wiki_dir",
|
|
1382
|
+
question="Qual pasta e a Wiki_Medicina correta para este usuario?",
|
|
1383
|
+
developer_summary="A resolucao encontrou mais de uma candidata plausivel; escolher automaticamente pode mutar a Wiki errada.",
|
|
1384
|
+
resume_action=(
|
|
1385
|
+
f"Registrar a opcao escolhida em {config_path} [paths].wiki_dir "
|
|
1386
|
+
"e repetir o workflow."
|
|
1387
|
+
),
|
|
1388
|
+
options=options,
|
|
1389
|
+
evidence=[
|
|
1390
|
+
DecisionEvidence(
|
|
1391
|
+
summary="Mais de uma candidata de Wiki foi encontrada.",
|
|
1392
|
+
technical_code="ambiguous_wiki_dir",
|
|
1393
|
+
source="mednotes.platform.paths",
|
|
1394
|
+
candidates=[
|
|
1395
|
+
{
|
|
1396
|
+
"path": str(candidate.path),
|
|
1397
|
+
"raw_dir": str(candidate.raw_dir) if candidate.raw_dir is not None else "",
|
|
1398
|
+
"source": candidate.source,
|
|
1399
|
+
"reason": candidate.reason,
|
|
1400
|
+
}
|
|
1401
|
+
for candidate in distinct
|
|
1402
|
+
],
|
|
1403
|
+
risk="wrong_vault_mutation",
|
|
1404
|
+
)
|
|
1405
|
+
],
|
|
1406
|
+
)
|
|
1407
|
+
return packet.model_copy(update={"context": {"config_path": str(config_path)}})
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _path_human_decision_packet(
|
|
1411
|
+
*,
|
|
1412
|
+
kind: str,
|
|
1413
|
+
phase: str,
|
|
1414
|
+
reason_code: str,
|
|
1415
|
+
question: str,
|
|
1416
|
+
developer_summary: str,
|
|
1417
|
+
resume_action: str,
|
|
1418
|
+
options: list[dict[str, object]],
|
|
1419
|
+
evidence: list[DecisionEvidence],
|
|
1420
|
+
) -> HumanDecisionPacket:
|
|
1421
|
+
"""Build path-choice packets through the canonical workflow decision model."""
|
|
1422
|
+
|
|
1423
|
+
decision = WorkflowDecision(
|
|
1424
|
+
kind="ask_human",
|
|
1425
|
+
phase=phase,
|
|
1426
|
+
reason_code=reason_code,
|
|
1427
|
+
public_summary=question,
|
|
1428
|
+
developer_summary=developer_summary,
|
|
1429
|
+
evidence=evidence,
|
|
1430
|
+
next_action=resume_action,
|
|
1431
|
+
resume_action=resume_action,
|
|
1432
|
+
rejected_automations=[
|
|
1433
|
+
RejectedAutomation(
|
|
1434
|
+
kind="auto_fix",
|
|
1435
|
+
reason_code=reason_code,
|
|
1436
|
+
reason="Path mutation without explicit choice can target the wrong vault.",
|
|
1437
|
+
safe=False,
|
|
1438
|
+
),
|
|
1439
|
+
RejectedAutomation(
|
|
1440
|
+
kind="auto_defer",
|
|
1441
|
+
reason_code=reason_code,
|
|
1442
|
+
reason="Deferring without a closed choice leaves setup blocked.",
|
|
1443
|
+
safe=False,
|
|
1444
|
+
),
|
|
1445
|
+
RejectedAutomation(
|
|
1446
|
+
kind="auto_plan",
|
|
1447
|
+
reason_code=reason_code,
|
|
1448
|
+
reason="Planning cannot disambiguate user-owned local paths.",
|
|
1449
|
+
safe=False,
|
|
1450
|
+
),
|
|
1451
|
+
],
|
|
1452
|
+
recommended_option_id=str(options[0]["id"]),
|
|
1453
|
+
options=options,
|
|
1454
|
+
human_decision_kind=kind,
|
|
1455
|
+
)
|
|
1456
|
+
return HumanDecisionPacket.model_validate(decision.to_human_decision_packet())
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _gemini_probe_context(
|
|
1460
|
+
*,
|
|
1461
|
+
start: Path | None,
|
|
1462
|
+
context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
|
|
1463
|
+
memory_path: Path,
|
|
1464
|
+
) -> str:
|
|
1465
|
+
budget = _probe_context_budget()
|
|
1466
|
+
snippets: list[str] = []
|
|
1467
|
+
queue = _gemini_context_seed_files(start=start, context_paths=context_paths, memory_path=memory_path)
|
|
1468
|
+
seen: set[str] = set()
|
|
1469
|
+
used = 0
|
|
1470
|
+
|
|
1471
|
+
while queue and used < budget:
|
|
1472
|
+
path = queue.pop(0).expanduser().resolve(strict=False)
|
|
1473
|
+
key = os.path.normcase(str(path))
|
|
1474
|
+
if key in seen or not path.is_file():
|
|
1475
|
+
continue
|
|
1476
|
+
seen.add(key)
|
|
1477
|
+
try:
|
|
1478
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1479
|
+
except OSError:
|
|
1480
|
+
continue
|
|
1481
|
+
remaining = max(0, budget - used)
|
|
1482
|
+
if remaining <= 0:
|
|
1483
|
+
break
|
|
1484
|
+
header = f"--- FILE: {path} ---\n"
|
|
1485
|
+
body = text[: max(0, remaining - len(header) - 2)]
|
|
1486
|
+
snippets.append(header + body + "\n")
|
|
1487
|
+
used += len(header) + len(body) + 1
|
|
1488
|
+
for ref in _markdown_context_references(text):
|
|
1489
|
+
resolved = _resolve_context_reference(path, ref)
|
|
1490
|
+
if resolved is not None:
|
|
1491
|
+
queue.append(resolved)
|
|
1492
|
+
|
|
1493
|
+
return "\n".join(snippets)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _probe_context_budget() -> int:
|
|
1497
|
+
raw = os.getenv("MEDNOTES_GEMINI_PATH_PROBE_CONTEXT_BYTES", "60000").strip()
|
|
1498
|
+
try:
|
|
1499
|
+
value = int(raw)
|
|
1500
|
+
except ValueError:
|
|
1501
|
+
return 60000
|
|
1502
|
+
return max(4096, min(value, 200000))
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _gemini_context_seed_files(
|
|
1506
|
+
*,
|
|
1507
|
+
start: Path | None,
|
|
1508
|
+
context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
|
|
1509
|
+
memory_path: Path,
|
|
1510
|
+
) -> list[Path]:
|
|
1511
|
+
seeds: list[Path] = []
|
|
1512
|
+
|
|
1513
|
+
def add(path: Path) -> None:
|
|
1514
|
+
key = os.path.normcase(str(path.expanduser().resolve(strict=False)))
|
|
1515
|
+
if key not in {os.path.normcase(str(item.expanduser().resolve(strict=False))) for item in seeds}:
|
|
1516
|
+
seeds.append(path)
|
|
1517
|
+
|
|
1518
|
+
add(memory_path)
|
|
1519
|
+
add(Path.home() / ".gemini" / "GEMINI.md")
|
|
1520
|
+
|
|
1521
|
+
raw_roots: list[Path] = []
|
|
1522
|
+
if start is not None:
|
|
1523
|
+
raw_roots.append(start)
|
|
1524
|
+
if context_paths:
|
|
1525
|
+
raw_roots.extend(expand_path(item) for item in context_paths)
|
|
1526
|
+
raw_roots.append(Path.cwd())
|
|
1527
|
+
|
|
1528
|
+
for raw in raw_roots:
|
|
1529
|
+
path = raw.expanduser().resolve(strict=False)
|
|
1530
|
+
scan_start = path if path.suffix == "" else path.parent
|
|
1531
|
+
if path.exists() and path.is_file():
|
|
1532
|
+
add(path)
|
|
1533
|
+
scan_start = path.parent
|
|
1534
|
+
for directory in (scan_start, *scan_start.parents):
|
|
1535
|
+
add(directory / "GEMINI.md")
|
|
1536
|
+
|
|
1537
|
+
extension_root = _infer_extension_root()
|
|
1538
|
+
add(extension_root / "GEMINI.md")
|
|
1539
|
+
add(extension_root / "extension" / "GEMINI.md")
|
|
1540
|
+
return seeds
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _markdown_context_references(text: str) -> list[str]:
|
|
1544
|
+
refs: list[str] = []
|
|
1545
|
+
refs.extend(match.group(1) for match in _MARKDOWN_AT_REF_RE.finditer(text))
|
|
1546
|
+
refs.extend(match.group(1) for match in _MARKDOWN_LINK_REF_RE.finditer(text))
|
|
1547
|
+
refs.extend(match.group(0) for match in _BARE_MARKDOWN_CONTEXT_REF_RE.finditer(text))
|
|
1548
|
+
return refs
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _resolve_context_reference(base_file: Path, ref: str) -> Path | None:
|
|
1552
|
+
if "://" in ref:
|
|
1553
|
+
return None
|
|
1554
|
+
clean = ref.strip().strip("<>").split("#", 1)[0].split("?", 1)[0]
|
|
1555
|
+
if not clean:
|
|
1556
|
+
return None
|
|
1557
|
+
path = expand_path(clean)
|
|
1558
|
+
if not path.is_absolute():
|
|
1559
|
+
path = base_file.parent / path
|
|
1560
|
+
return path
|