dsh-aris-panel 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/LICENSE +21 -0
- package/README.md +98 -0
- package/README_CN.md +87 -0
- package/dsh/checkout.patch.yml +38 -0
- package/dsh/client.js +634 -0
- package/dsh/cordis.patch.yml +44 -0
- package/dsh/index.mjs +76 -0
- package/dsh/run-status.mjs +182 -0
- package/dsh/scope-limits.mjs +50 -0
- package/dsh/workbench.mjs +291 -0
- package/mcp-servers/claude-review/README.md +93 -0
- package/mcp-servers/claude-review/run_with_claude_aws.sh +49 -0
- package/mcp-servers/claude-review/server.py +718 -0
- package/mcp-servers/codex-image2/README.md +65 -0
- package/mcp-servers/codex-image2/server.py +893 -0
- package/mcp-servers/feishu-bridge/requirements.txt +1 -0
- package/mcp-servers/feishu-bridge/server.py +240 -0
- package/mcp-servers/gemini-review/README.md +171 -0
- package/mcp-servers/gemini-review/server.py +1856 -0
- package/mcp-servers/llm-chat/requirements.txt +1 -0
- package/mcp-servers/llm-chat/server.py +664 -0
- package/mcp-servers/manual-review/README.md +133 -0
- package/mcp-servers/manual-review/server.py +910 -0
- package/mcp-servers/manual-review/ui.html +279 -0
- package/mcp-servers/minimax-chat/requirements.txt +1 -0
- package/mcp-servers/minimax-chat/server.py +381 -0
- package/package.json +51 -0
- package/skills/ablation-planner/SKILL.md +123 -0
- package/skills/alphaxiv/SKILL.md +196 -0
- package/skills/analyze-results/SKILL.md +46 -0
- package/skills/arxiv/SKILL.md +248 -0
- package/skills/auto-paper-improvement-loop/SKILL.md +651 -0
- package/skills/auto-review-loop/SKILL.md +1137 -0
- package/skills/auto-review-loop-llm/SKILL.md +259 -0
- package/skills/auto-review-loop-minimax/SKILL.md +302 -0
- package/skills/citation-audit/SKILL.md +502 -0
- package/skills/claims-drafting/SKILL.md +227 -0
- package/skills/comm-lit-review/SKILL.md +297 -0
- package/skills/deepxiv/SKILL.md +263 -0
- package/skills/dse-loop/SKILL.md +296 -0
- package/skills/embodiment-description/SKILL.md +129 -0
- package/skills/exa-search/SKILL.md +205 -0
- package/skills/experiment-audit/SKILL.md +311 -0
- package/skills/experiment-bridge/SKILL.md +376 -0
- package/skills/experiment-plan/SKILL.md +249 -0
- package/skills/experiment-queue/SKILL.md +431 -0
- package/skills/experiment-queue/scripts/build_manifest.py +142 -0
- package/skills/experiment-queue/scripts/queue_manager.py +433 -0
- package/skills/feishu-notify/SKILL.md +156 -0
- package/skills/figure-description/SKILL.md +138 -0
- package/skills/figure-spec/SKILL.md +262 -0
- package/skills/figure-spec/scripts/figure_renderer.py +799 -0
- package/skills/formula-derivation/SKILL.md +280 -0
- package/skills/gemini-search/SKILL.md +231 -0
- package/skills/grant-proposal/SKILL.md +698 -0
- package/skills/idea-creator/SKILL.md +542 -0
- package/skills/idea-discovery/SKILL.md +521 -0
- package/skills/idea-discovery-robot/SKILL.md +363 -0
- package/skills/integrity-forensics/SKILL.md +284 -0
- package/skills/interview-cheatsheet/SKILL.md +245 -0
- package/skills/invention-structuring/SKILL.md +188 -0
- package/skills/jurisdiction-format/SKILL.md +192 -0
- package/skills/kill-argument/SKILL.md +437 -0
- package/skills/mermaid-diagram/SKILL.md +419 -0
- package/skills/meta-apply/SKILL.md +141 -0
- package/skills/meta-optimize/SKILL.md +437 -0
- package/skills/monitor-experiment/SKILL.md +140 -0
- package/skills/novelty-check/SKILL.md +101 -0
- package/skills/openalex/SKILL.md +237 -0
- package/skills/overleaf-sync/SKILL.md +220 -0
- package/skills/paper-claim-audit/SKILL.md +348 -0
- package/skills/paper-compile/SKILL.md +266 -0
- package/skills/paper-figure/SKILL.md +312 -0
- package/skills/paper-illustration/SKILL.md +736 -0
- package/skills/paper-illustration-image2/SKILL.md +391 -0
- package/skills/paper-illustration-image2/scripts/paper_illustration_image2.py +255 -0
- package/skills/paper-plan/SKILL.md +386 -0
- package/skills/paper-poster/SKILL.md +19 -0
- package/skills/paper-poster-html/DESIGN_FINAL.md +176 -0
- package/skills/paper-poster-html/IMPLEMENTATION_CONVENTIONS.md +161 -0
- package/skills/paper-poster-html/LICENSES/posterly-MIT.txt +21 -0
- package/skills/paper-poster-html/NOTICE.md +57 -0
- package/skills/paper-poster-html/SKILL.md +323 -0
- package/skills/paper-poster-html/scripts/_posterly/__init__.py +0 -0
- package/skills/paper-poster-html/scripts/_posterly/canvas.py +200 -0
- package/skills/paper-poster-html/scripts/_posterly/measure.py +588 -0
- package/skills/paper-poster-html/scripts/_posterly/polish.py +498 -0
- package/skills/paper-poster-html/scripts/_posterly/preflight.py +489 -0
- package/skills/paper-poster-html/scripts/_posterly/render.py +215 -0
- package/skills/paper-poster-html/scripts/_posterly/textutil.py +16 -0
- package/skills/paper-poster-html/scripts/_posterly/verify_final.py +171 -0
- package/skills/paper-poster-html/scripts/asset_check.py +897 -0
- package/skills/paper-poster-html/scripts/extract_pdf_figures.py +666 -0
- package/skills/paper-poster-html/scripts/poster_check.py +251 -0
- package/skills/paper-poster-html/scripts/preprocess_figures.py +238 -0
- package/skills/paper-poster-html/scripts/render_preview.py +217 -0
- package/skills/paper-poster-html/scripts/run_gates.py +556 -0
- package/skills/paper-poster-html/scripts/style_check.py +1324 -0
- package/skills/paper-poster-html/templates/COMPONENTS.md +462 -0
- package/skills/paper-poster-html/templates/README.md +170 -0
- package/skills/paper-poster-html/templates/landscape_4col.html +1032 -0
- package/skills/paper-poster-html/templates/landscape_hero.html +1046 -0
- package/skills/paper-poster-html/templates/portrait_2col.html +947 -0
- package/skills/paper-poster-html/templates/tokens/acl.json +9 -0
- package/skills/paper-poster-html/templates/tokens/cvpr.json +9 -0
- package/skills/paper-poster-html/templates/tokens/generic.json +9 -0
- package/skills/paper-poster-html/templates/tokens/iclr.json +9 -0
- package/skills/paper-poster-html/templates/tokens/icml.json +9 -0
- package/skills/paper-poster-html/templates/tokens/neurips.json +9 -0
- package/skills/paper-slides/SKILL.md +635 -0
- package/skills/paper-talk/SKILL.md +381 -0
- package/skills/paper-write/SKILL.md +604 -0
- package/skills/paper-write/templates/IEEEtran.bst +2409 -0
- package/skills/paper-write/templates/IEEEtran.cls +6347 -0
- package/skills/paper-write/templates/iclr2026.tex +84 -0
- package/skills/paper-write/templates/icml2025.tex +87 -0
- package/skills/paper-write/templates/ieee_conference.tex +89 -0
- package/skills/paper-write/templates/ieee_journal.tex +93 -0
- package/skills/paper-write/templates/math_commands.tex +48 -0
- package/skills/paper-write/templates/neurips2025.tex +80 -0
- package/skills/paper-writing/SKILL.md +916 -0
- package/skills/patent-novelty-check/SKILL.md +153 -0
- package/skills/patent-pipeline/SKILL.md +344 -0
- package/skills/patent-review/SKILL.md +203 -0
- package/skills/pixel-art/SKILL.md +137 -0
- package/skills/prior-art-search/SKILL.md +146 -0
- package/skills/proof-checker/SKILL.md +866 -0
- package/skills/proof-orchestrator/NOTICE.md +24 -0
- package/skills/proof-orchestrator/SKILL.md +254 -0
- package/skills/proof-orchestrator/references/audit-output-contract.md +126 -0
- package/skills/proof-orchestrator/references/deepseek-routing.md +74 -0
- package/skills/proof-orchestrator/references/dispatch-prompts.md +227 -0
- package/skills/proof-orchestrator/references/notation-audit.md +135 -0
- package/skills/proof-orchestrator/references/proof-audit-rubric.md +70 -0
- package/skills/proof-orchestrator/references/stress-tests.md +38 -0
- package/skills/proof-writer/SKILL.md +223 -0
- package/skills/qzcli/SKILL.md +324 -0
- package/skills/rebuttal/SKILL.md +376 -0
- package/skills/render-html/SKILL.md +316 -0
- package/skills/render-html/scripts/render_html.py +1006 -0
- package/skills/render-html/scripts/templates/academic.html +703 -0
- package/skills/render-html/scripts/templates/dashboard.html +333 -0
- package/skills/research-lit/SKILL.md +756 -0
- package/skills/research-pipeline/SKILL.md +384 -0
- package/skills/research-refine/SKILL.md +770 -0
- package/skills/research-refine-pipeline/SKILL.md +186 -0
- package/skills/research-review/SKILL.md +198 -0
- package/skills/research-wiki/SKILL.md +461 -0
- package/skills/resubmit-pipeline/SKILL.md +447 -0
- package/skills/result-to-claim/SKILL.md +311 -0
- package/skills/run-experiment/SKILL.md +313 -0
- package/skills/semantic-scholar/SKILL.md +236 -0
- package/skills/serverless-modal/SKILL.md +335 -0
- package/skills/shared-references/acceptance-gate.md +324 -0
- package/skills/shared-references/assurance-contract.md +248 -0
- package/skills/shared-references/capture-antipatterns.md +78 -0
- package/skills/shared-references/citation-discipline.md +583 -0
- package/skills/shared-references/compute-env-contract.md +163 -0
- package/skills/shared-references/effort-contract.md +183 -0
- package/skills/shared-references/evidence-precheck.md +65 -0
- package/skills/shared-references/experiment-integrity.md +49 -0
- package/skills/shared-references/external-cadence.md +326 -0
- package/skills/shared-references/fan-out-pattern.md +366 -0
- package/skills/shared-references/injection-hygiene.md +127 -0
- package/skills/shared-references/integration-contract.md +461 -0
- package/skills/shared-references/output-composition.md +93 -0
- package/skills/shared-references/output-language.md +45 -0
- package/skills/shared-references/output-manifest.md +49 -0
- package/skills/shared-references/output-versioning.md +111 -0
- package/skills/shared-references/patent-format-cn.md +199 -0
- package/skills/shared-references/patent-format-ep.md +173 -0
- package/skills/shared-references/patent-format-us.md +161 -0
- package/skills/shared-references/patent-writing-principles.md +197 -0
- package/skills/shared-references/prior-art-databases.md +141 -0
- package/skills/shared-references/resumable-runs.md +109 -0
- package/skills/shared-references/review-scope-limits.md +81 -0
- package/skills/shared-references/review-tracing.md +391 -0
- package/skills/shared-references/reviewer-independence.md +79 -0
- package/skills/shared-references/reviewer-routing.md +852 -0
- package/skills/shared-references/skill-governance.md +104 -0
- package/skills/shared-references/taste-calibration.md +85 -0
- package/skills/shared-references/venue-checklists.md +114 -0
- package/skills/shared-references/wiki-helper-resolution.md +134 -0
- package/skills/shared-references/writing-principles.md +525 -0
- package/skills/skills-codex/README.md +102 -0
- package/skills/skills-codex/README_CN.md +100 -0
- package/skills/skills-codex/ablation-planner/SKILL.md +126 -0
- package/skills/skills-codex/alphaxiv/SKILL.md +186 -0
- package/skills/skills-codex/analyze-results/SKILL.md +45 -0
- package/skills/skills-codex/arxiv/SKILL.md +210 -0
- package/skills/skills-codex/auto-paper-improvement-loop/SKILL.md +574 -0
- package/skills/skills-codex/auto-review-loop/SKILL.md +500 -0
- package/skills/skills-codex/auto-review-loop-llm/SKILL.md +247 -0
- package/skills/skills-codex/auto-review-loop-minimax/SKILL.md +290 -0
- package/skills/skills-codex/citation-audit/SKILL.md +504 -0
- package/skills/skills-codex/claims-drafting/SKILL.md +239 -0
- package/skills/skills-codex/comm-lit-review/SKILL.md +299 -0
- package/skills/skills-codex/comm-lit-review/references/domain-taxonomy.md +57 -0
- package/skills/skills-codex/comm-lit-review/references/output-template.md +37 -0
- package/skills/skills-codex/comm-lit-review/references/source-policy.md +99 -0
- package/skills/skills-codex/comm-lit-review/references/venue-tiering.md +112 -0
- package/skills/skills-codex/deepxiv/SKILL.md +142 -0
- package/skills/skills-codex/dse-loop/SKILL.md +285 -0
- package/skills/skills-codex/embodiment-description/SKILL.md +129 -0
- package/skills/skills-codex/exa-search/SKILL.md +192 -0
- package/skills/skills-codex/experiment-audit/SKILL.md +286 -0
- package/skills/skills-codex/experiment-bridge/SKILL.md +356 -0
- package/skills/skills-codex/experiment-plan/SKILL.md +249 -0
- package/skills/skills-codex/experiment-queue/SKILL.md +401 -0
- package/skills/skills-codex/feishu-notify/SKILL.md +155 -0
- package/skills/skills-codex/figure-description/SKILL.md +138 -0
- package/skills/skills-codex/figure-spec/SKILL.md +252 -0
- package/skills/skills-codex/formula-derivation/SKILL.md +280 -0
- package/skills/skills-codex/gemini-search/SKILL.md +205 -0
- package/skills/skills-codex/grant-proposal/SKILL.md +626 -0
- package/skills/skills-codex/idea-creator/SKILL.md +405 -0
- package/skills/skills-codex/idea-discovery/SKILL.md +475 -0
- package/skills/skills-codex/idea-discovery-robot/SKILL.md +362 -0
- package/skills/skills-codex/integrity-forensics/SKILL.md +106 -0
- package/skills/skills-codex/interview-cheatsheet/SKILL.md +245 -0
- package/skills/skills-codex/invention-structuring/SKILL.md +188 -0
- package/skills/skills-codex/jurisdiction-format/SKILL.md +192 -0
- package/skills/skills-codex/kill-argument/SKILL.md +403 -0
- package/skills/skills-codex/mermaid-diagram/SKILL.md +379 -0
- package/skills/skills-codex/meta-apply/SKILL.md +154 -0
- package/skills/skills-codex/meta-optimize/SKILL.md +348 -0
- package/skills/skills-codex/monitor-experiment/SKILL.md +98 -0
- package/skills/skills-codex/novelty-check/SKILL.md +89 -0
- package/skills/skills-codex/openalex/SKILL.md +228 -0
- package/skills/skills-codex/overleaf-sync/SKILL.md +220 -0
- package/skills/skills-codex/paper-claim-audit/SKILL.md +350 -0
- package/skills/skills-codex/paper-compile/SKILL.md +253 -0
- package/skills/skills-codex/paper-figure/SKILL.md +311 -0
- package/skills/skills-codex/paper-illustration/SKILL.md +690 -0
- package/skills/skills-codex/paper-illustration-image2/SKILL.md +383 -0
- package/skills/skills-codex/paper-illustration-image2/scripts/paper_illustration_image2.py +255 -0
- package/skills/skills-codex/paper-plan/SKILL.md +278 -0
- package/skills/skills-codex/paper-poster/SKILL.md +19 -0
- package/skills/skills-codex/paper-poster-html/SKILL.md +377 -0
- package/skills/skills-codex/paper-slides/SKILL.md +571 -0
- package/skills/skills-codex/paper-talk/SKILL.md +381 -0
- package/skills/skills-codex/paper-write/SKILL.md +411 -0
- package/skills/skills-codex/paper-write/templates/IEEEtran.bst +2409 -0
- package/skills/skills-codex/paper-write/templates/IEEEtran.cls +6347 -0
- package/skills/skills-codex/paper-write/templates/aaai2026.bst +1493 -0
- package/skills/skills-codex/paper-write/templates/aaai2026.sty +315 -0
- package/skills/skills-codex/paper-write/templates/aaai2026.tex +952 -0
- package/skills/skills-codex/paper-write/templates/acl.sty +312 -0
- package/skills/skills-codex/paper-write/templates/acl2026.tex +377 -0
- package/skills/skills-codex/paper-write/templates/acl_natbib.bst +1940 -0
- package/skills/skills-codex/paper-write/templates/acm.bst +3081 -0
- package/skills/skills-codex/paper-write/templates/acm_mm2026.tex +204 -0
- package/skills/skills-codex/paper-write/templates/acmart.cls +3520 -0
- package/skills/skills-codex/paper-write/templates/cvpr.bst +1448 -0
- package/skills/skills-codex/paper-write/templates/cvpr.sty +508 -0
- package/skills/skills-codex/paper-write/templates/cvpr2026.tex +63 -0
- package/skills/skills-codex/paper-write/templates/iclr2026.tex +84 -0
- package/skills/skills-codex/paper-write/templates/iclr2026_conference.bst +1440 -0
- package/skills/skills-codex/paper-write/templates/iclr2026_conference.sty +246 -0
- package/skills/skills-codex/paper-write/templates/icml2026.sty +767 -0
- package/skills/skills-codex/paper-write/templates/icml2026.tex +662 -0
- package/skills/skills-codex/paper-write/templates/ieee_conference.tex +89 -0
- package/skills/skills-codex/paper-write/templates/ieee_journal.tex +93 -0
- package/skills/skills-codex/paper-write/templates/math_commands.tex +48 -0
- package/skills/skills-codex/paper-write/templates/neurips2026.tex +493 -0
- package/skills/skills-codex/paper-write/templates/neurips_2026.sty +437 -0
- package/skills/skills-codex/paper-writing/SKILL.md +731 -0
- package/skills/skills-codex/patent-novelty-check/SKILL.md +153 -0
- package/skills/skills-codex/patent-pipeline/SKILL.md +344 -0
- package/skills/skills-codex/patent-review/SKILL.md +202 -0
- package/skills/skills-codex/pixel-art/SKILL.md +139 -0
- package/skills/skills-codex/prior-art-search/SKILL.md +146 -0
- package/skills/skills-codex/proof-checker/SKILL.md +554 -0
- package/skills/skills-codex/proof-orchestrator/SKILL.md +260 -0
- package/skills/skills-codex/proof-orchestrator/references/audit-output-contract.md +126 -0
- package/skills/skills-codex/proof-orchestrator/references/deepseek-routing.md +76 -0
- package/skills/skills-codex/proof-orchestrator/references/dispatch-prompts.md +227 -0
- package/skills/skills-codex/proof-orchestrator/references/notation-audit.md +135 -0
- package/skills/skills-codex/proof-orchestrator/references/proof-audit-rubric.md +70 -0
- package/skills/skills-codex/proof-orchestrator/references/stress-tests.md +38 -0
- package/skills/skills-codex/proof-writer/SKILL.md +222 -0
- package/skills/skills-codex/qzcli/SKILL.md +324 -0
- package/skills/skills-codex/rebuttal/SKILL.md +305 -0
- package/skills/skills-codex/render-html/SKILL.md +305 -0
- package/skills/skills-codex/render-html/scripts/__pycache__/render_html.cpython-314.pyc +0 -0
- package/skills/skills-codex/render-html/scripts/render_html.py +909 -0
- package/skills/skills-codex/render-html/scripts/templates/academic.html +342 -0
- package/skills/skills-codex/render-html/scripts/templates/dashboard.html +333 -0
- package/skills/skills-codex/research-lit/SKILL.md +464 -0
- package/skills/skills-codex/research-pipeline/SKILL.md +340 -0
- package/skills/skills-codex/research-refine/SKILL.md +721 -0
- package/skills/skills-codex/research-refine-pipeline/SKILL.md +186 -0
- package/skills/skills-codex/research-review/SKILL.md +135 -0
- package/skills/skills-codex/research-wiki/SKILL.md +421 -0
- package/skills/skills-codex/resubmit-pipeline/SKILL.md +444 -0
- package/skills/skills-codex/result-to-claim/SKILL.md +246 -0
- package/skills/skills-codex/run-experiment/SKILL.md +236 -0
- package/skills/skills-codex/semantic-scholar/SKILL.md +219 -0
- package/skills/skills-codex/serverless-modal/SKILL.md +335 -0
- package/skills/skills-codex/shared-references/acceptance-gate.md +336 -0
- package/skills/skills-codex/shared-references/assurance-contract.md +139 -0
- package/skills/skills-codex/shared-references/capture-antipatterns.md +84 -0
- package/skills/skills-codex/shared-references/citation-discipline.md +452 -0
- package/skills/skills-codex/shared-references/compute-env-contract.md +163 -0
- package/skills/skills-codex/shared-references/effort-contract.md +143 -0
- package/skills/skills-codex/shared-references/evidence-precheck.md +73 -0
- package/skills/skills-codex/shared-references/experiment-integrity.md +49 -0
- package/skills/skills-codex/shared-references/external-cadence.md +334 -0
- package/skills/skills-codex/shared-references/fan-out-pattern.md +375 -0
- package/skills/skills-codex/shared-references/injection-hygiene.md +132 -0
- package/skills/skills-codex/shared-references/integration-contract.md +372 -0
- package/skills/skills-codex/shared-references/output-composition.md +98 -0
- package/skills/skills-codex/shared-references/output-language.md +45 -0
- package/skills/skills-codex/shared-references/output-manifest.md +40 -0
- package/skills/skills-codex/shared-references/output-versioning.md +111 -0
- package/skills/skills-codex/shared-references/patent-format-cn.md +199 -0
- package/skills/skills-codex/shared-references/patent-format-ep.md +173 -0
- package/skills/skills-codex/shared-references/patent-format-us.md +161 -0
- package/skills/skills-codex/shared-references/patent-writing-principles.md +197 -0
- package/skills/skills-codex/shared-references/prior-art-databases.md +141 -0
- package/skills/skills-codex/shared-references/resumable-runs.md +125 -0
- package/skills/skills-codex/shared-references/review-scope-limits.md +81 -0
- package/skills/skills-codex/shared-references/review-tracing.md +144 -0
- package/skills/skills-codex/shared-references/reviewer-independence.md +66 -0
- package/skills/skills-codex/shared-references/reviewer-routing.md +128 -0
- package/skills/skills-codex/shared-references/skill-governance.md +119 -0
- package/skills/skills-codex/shared-references/taste-calibration.md +90 -0
- package/skills/skills-codex/shared-references/venue-checklists.md +73 -0
- package/skills/skills-codex/shared-references/wiki-helper-resolution.md +69 -0
- package/skills/skills-codex/shared-references/writing-principles.md +525 -0
- package/skills/skills-codex/slides-polish/SKILL.md +563 -0
- package/skills/skills-codex/specification-writing/SKILL.md +211 -0
- package/skills/skills-codex/system-profile/SKILL.md +103 -0
- package/skills/skills-codex/training-check/SKILL.md +83 -0
- package/skills/skills-codex/vast-gpu/SKILL.md +394 -0
- package/skills/skills-codex/web-debug-search/SKILL.md +334 -0
- package/skills/skills-codex/wiki-enrich/SKILL.md +255 -0
- package/skills/skills-codex/writing-systems-papers/SKILL.md +184 -0
- package/skills/skills-codex-claude-review/README.md +79 -0
- package/skills/skills-codex-claude-review/README_CN.md +78 -0
- package/skills/skills-codex-claude-review/auto-paper-improvement-loop/SKILL.md +581 -0
- package/skills/skills-codex-claude-review/auto-review-loop/SKILL.md +510 -0
- package/skills/skills-codex-claude-review/novelty-check/SKILL.md +102 -0
- package/skills/skills-codex-claude-review/paper-figure/SKILL.md +319 -0
- package/skills/skills-codex-claude-review/paper-plan/SKILL.md +287 -0
- package/skills/skills-codex-claude-review/paper-write/SKILL.md +420 -0
- package/skills/skills-codex-claude-review/research-refine/SKILL.md +732 -0
- package/skills/skills-codex-claude-review/research-review/SKILL.md +149 -0
- package/skills/skills-codex-gemini-review/README.md +176 -0
- package/skills/skills-codex-gemini-review/README_CN.md +175 -0
- package/skills/skills-codex-gemini-review/auto-paper-improvement-loop/SKILL.md +331 -0
- package/skills/skills-codex-gemini-review/auto-review-loop/SKILL.md +304 -0
- package/skills/skills-codex-gemini-review/grant-proposal/SKILL.md +630 -0
- package/skills/skills-codex-gemini-review/idea-creator/SKILL.md +263 -0
- package/skills/skills-codex-gemini-review/idea-discovery/SKILL.md +275 -0
- package/skills/skills-codex-gemini-review/idea-discovery-robot/SKILL.md +365 -0
- package/skills/skills-codex-gemini-review/novelty-check/SKILL.md +92 -0
- package/skills/skills-codex-gemini-review/paper-figure/SKILL.md +289 -0
- package/skills/skills-codex-gemini-review/paper-plan/SKILL.md +265 -0
- package/skills/skills-codex-gemini-review/paper-poster-html/SKILL.md +102 -0
- package/skills/skills-codex-gemini-review/paper-slides/SKILL.md +582 -0
- package/skills/skills-codex-gemini-review/paper-write/SKILL.md +346 -0
- package/skills/skills-codex-gemini-review/paper-writing/SKILL.md +312 -0
- package/skills/skills-codex-gemini-review/research-refine/SKILL.md +674 -0
- package/skills/skills-codex-gemini-review/research-review/SKILL.md +112 -0
- package/skills/slides-polish/SKILL.md +565 -0
- package/skills/specification-writing/SKILL.md +211 -0
- package/skills/system-profile/SKILL.md +103 -0
- package/skills/training-check/SKILL.md +132 -0
- package/skills/vast-gpu/SKILL.md +394 -0
- package/skills/web-debug-search/SKILL.md +334 -0
- package/skills/wiki-enrich/SKILL.md +257 -0
- package/skills/writing-systems-papers/SKILL.md +184 -0
- package/templates/CLAUDE_MD_TEMPLATE.md +29 -0
- package/templates/EXPERIMENT_LOG_TEMPLATE.md +47 -0
- package/templates/EXPERIMENT_PLAN_TEMPLATE.md +51 -0
- package/templates/EXPERIMENT_PLAN_TEMPLATE_CN.md +53 -0
- package/templates/FINDINGS_TEMPLATE.md +52 -0
- package/templates/IDEA_CANDIDATES_TEMPLATE.md +47 -0
- package/templates/IDEA_CANDIDATES_TEMPLATE_CN.md +47 -0
- package/templates/INVENTION_BRIEF_TEMPLATE.md +87 -0
- package/templates/MANIFEST_TEMPLATE.md +7 -0
- package/templates/NARRATIVE_REPORT_TEMPLATE.md +49 -0
- package/templates/PAPER_PLAN_TEMPLATE.md +47 -0
- package/templates/PATENT_CLAIMS_TEMPLATE.md +78 -0
- package/templates/PATENT_SPECIFICATION_TEMPLATE.md +68 -0
- package/templates/README.md +57 -0
- package/templates/RESEARCH_BRIEF_TEMPLATE.md +35 -0
- package/templates/RESEARCH_BRIEF_TEMPLATE_CN.md +41 -0
- package/templates/RESEARCH_CONTRACT_TEMPLATE.md +60 -0
- package/templates/claude-hooks/corpus_write_guard.json +16 -0
- package/templates/claude-hooks/corpus_write_guard.py +85 -0
- package/templates/claude-hooks/meta_logging.json +74 -0
- package/templates/gitignore-trace.txt +3 -0
- package/tools/__pycache__/check_skills_inventory.cpython-314.pyc +0 -0
- package/tools/arxiv_fetch.py +311 -0
- package/tools/capture_filter.py +126 -0
- package/tools/check_skills_inventory.py +273 -0
- package/tools/convert_skills_to_llm_chat.py +282 -0
- package/tools/copilot_native_evidence.py +818 -0
- package/tools/deepxiv_fetch.py +213 -0
- package/tools/evidence_check.py +212 -0
- package/tools/exa_search.py +425 -0
- package/tools/experiment_queue/README.md +118 -0
- package/tools/experiment_queue/build_manifest.py +44 -0
- package/tools/experiment_queue/queue_manager.py +44 -0
- package/tools/extract_paper_style.py +560 -0
- package/tools/figure_renderer.py +69 -0
- package/tools/forensics_gate.py +669 -0
- package/tools/generate_codex_claude_review_overrides.py +299 -0
- package/tools/idea_discovery_gate.py +256 -0
- package/tools/install_aris.ps1 +1372 -0
- package/tools/install_aris.sh +1370 -0
- package/tools/install_aris_codex.sh +1023 -0
- package/tools/install_aris_copilot.sh +1052 -0
- package/tools/iteration_log.py +143 -0
- package/tools/lint_skills_helpers.sh +84 -0
- package/tools/meta_opt/check_ready.sh +80 -0
- package/tools/meta_opt/log_event.sh +91 -0
- package/tools/meta_opt/trigger_eval.py +280 -0
- package/tools/meta_opt/trigger_evals.sample.json +28 -0
- package/tools/openalex_fetch.py +326 -0
- package/tools/overleaf_audit.sh +104 -0
- package/tools/overleaf_setup.sh +150 -0
- package/tools/paper_illustration_image2.py +62 -0
- package/tools/provenance.py +294 -0
- package/tools/research_wiki.py +1720 -0
- package/tools/review_gate.py +502 -0
- package/tools/run_state.py +399 -0
- package/tools/save_trace.sh +477 -0
- package/tools/semantic_scholar_fetch.py +438 -0
- package/tools/skill-groups.tsv +116 -0
- package/tools/skill_picker.py +238 -0
- package/tools/smart_update.ps1 +521 -0
- package/tools/smart_update.sh +591 -0
- package/tools/smart_update_codex.sh +419 -0
- package/tools/smart_update_copilot.sh +605 -0
- package/tools/threat_scan.py +222 -0
- package/tools/verify_paper_audits.sh +487 -0
- package/tools/verify_papers.py +613 -0
- package/tools/verify_wiki_coverage.sh +176 -0
- package/tools/watchdog.py +485 -0
|
@@ -0,0 +1,1856 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gemini review MCP server for Codex-first workflows.
|
|
3
|
+
|
|
4
|
+
This server mirrors the narrow review-only interface used by the existing
|
|
5
|
+
review bridges, but defaults to the direct Gemini API so Codex can reuse the
|
|
6
|
+
original ARIS review-heavy skill structure with minimal changes. Gemini CLI is
|
|
7
|
+
kept only as an optional fallback. It is intentionally self-contained so it can
|
|
8
|
+
be copied into `~/.codex/mcp-servers/gemini-review/` without depending on this
|
|
9
|
+
repository.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import mimetypes
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import signal
|
|
20
|
+
import shutil
|
|
21
|
+
import stat
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
import uuid
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_stdio_initialized = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _init_stdio() -> None:
|
|
40
|
+
"""Rebind stdio to raw unbuffered binary streams for MCP framing.
|
|
41
|
+
|
|
42
|
+
Deferred into a function (called at the top of main()) so that merely
|
|
43
|
+
IMPORTING this module has no stdio side effects. os.fdopen(fileno) defaults
|
|
44
|
+
to closefd=True and thus seizes ownership of the fd; doing that at import
|
|
45
|
+
time under a test harness that captures stdio (pytest fd-capture) closes the
|
|
46
|
+
harness's capture fd and corrupts capture for every subsequent test. Real
|
|
47
|
+
server launch (python server.py) still calls this first via main(), so
|
|
48
|
+
runtime behavior is unchanged. Idempotent."""
|
|
49
|
+
global _stdio_initialized
|
|
50
|
+
if _stdio_initialized:
|
|
51
|
+
return
|
|
52
|
+
sys.stdout = os.fdopen(sys.stdout.fileno(), "wb", buffering=0)
|
|
53
|
+
sys.stdin = os.fdopen(sys.stdin.fileno(), "rb", buffering=0)
|
|
54
|
+
_stdio_initialized = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SERVER_NAME = os.environ.get("GEMINI_REVIEW_SERVER_NAME", "gemini-review")
|
|
58
|
+
GEMINI_BIN = os.environ.get("GEMINI_BIN", "gemini")
|
|
59
|
+
AGY_BIN = os.environ.get("AGY_BIN", "agy")
|
|
60
|
+
DEFAULT_MODEL = os.environ.get("GEMINI_REVIEW_MODEL", "")
|
|
61
|
+
DEFAULT_SYSTEM = os.environ.get("GEMINI_REVIEW_SYSTEM", "")
|
|
62
|
+
DEFAULT_BACKEND = os.environ.get("GEMINI_REVIEW_BACKEND", "api")
|
|
63
|
+
DEFAULT_TIMEOUT_SEC = int(os.environ.get("GEMINI_REVIEW_TIMEOUT_SEC", "600"))
|
|
64
|
+
DEFAULT_API_MODEL = os.environ.get("GEMINI_REVIEW_API_MODEL", "gemini-2.5-flash")
|
|
65
|
+
DEFAULT_AGY_PRINT_TIMEOUT = os.environ.get("GEMINI_REVIEW_AGY_PRINT_TIMEOUT", f"{DEFAULT_TIMEOUT_SEC}s")
|
|
66
|
+
MAX_STATUS_WAIT_SECONDS = int(os.environ.get("GEMINI_REVIEW_MAX_STATUS_WAIT_SECONDS", "30"))
|
|
67
|
+
AGY_APP_DATA_DIR = Path(
|
|
68
|
+
os.environ.get(
|
|
69
|
+
"GEMINI_REVIEW_AGY_APP_DATA_DIR",
|
|
70
|
+
str(Path.home() / ".gemini" / "antigravity-cli"),
|
|
71
|
+
)
|
|
72
|
+
).expanduser()
|
|
73
|
+
AGY_ARTIFACT_MAX_CHARS = int(os.environ.get("GEMINI_REVIEW_AGY_ARTIFACT_MAX_CHARS", "200000"))
|
|
74
|
+
WORKSPACE_ROOT = Path(os.environ.get("GEMINI_REVIEW_WORKSPACE_ROOT", os.getcwd())).expanduser()
|
|
75
|
+
STATE_DIR = Path(
|
|
76
|
+
os.environ.get(
|
|
77
|
+
"GEMINI_REVIEW_STATE_DIR",
|
|
78
|
+
str(Path.home() / ".codex" / "state" / SERVER_NAME),
|
|
79
|
+
)
|
|
80
|
+
).expanduser()
|
|
81
|
+
DEBUG_LOG = Path(os.environ.get("GEMINI_REVIEW_DEBUG_LOG", str(STATE_DIR / "debug.log"))).expanduser()
|
|
82
|
+
JOBS_DIR = STATE_DIR / "jobs"
|
|
83
|
+
THREADS_DIR = STATE_DIR / "threads"
|
|
84
|
+
|
|
85
|
+
_use_ndjson = False
|
|
86
|
+
TERMINAL_JOB_STATES = {"completed", "failed"}
|
|
87
|
+
SHARED_TEMP_DIRS = {Path("/tmp"), Path("/var/tmp")}
|
|
88
|
+
SAFE_ID_MAX_LEN = 128
|
|
89
|
+
SAFE_ID_RE = re.compile(r"^[0-9A-Za-z_-]+$")
|
|
90
|
+
GEMINI_MODEL_RE = re.compile(
|
|
91
|
+
r"^(?:gemini-[A-Za-z0-9][A-Za-z0-9_.:+-]*|models/gemini-[A-Za-z0-9][A-Za-z0-9_.:+-]*|publishers/google/models/gemini-[A-Za-z0-9][A-Za-z0-9_.:+-]*)$",
|
|
92
|
+
re.IGNORECASE,
|
|
93
|
+
)
|
|
94
|
+
GEMINI_LABEL_RE = re.compile(
|
|
95
|
+
r"^Gemini\s+[0-9][A-Za-z0-9 ._-]*(?:\s+\([A-Za-z0-9 ._-]+\))?$",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
MODEL_KEY_RE = re.compile(r"[^a-z0-9]+")
|
|
99
|
+
MODEL_KEY_NAMES = {
|
|
100
|
+
"model",
|
|
101
|
+
"modelid",
|
|
102
|
+
"modelname",
|
|
103
|
+
"selectedmodel",
|
|
104
|
+
"currentmodel",
|
|
105
|
+
"reviewermodel",
|
|
106
|
+
}
|
|
107
|
+
PRIVATE_ENV_KEYS = {"GEMINI_API_KEY", "GOOGLE_API_KEY"}
|
|
108
|
+
DEBUG_REDACT_KEYS = {"prompt", "system", "content", "text", "history", "imagePaths", "image_paths"}
|
|
109
|
+
MODEL_UNTRUSTED_CONTAINER_KEYS = {
|
|
110
|
+
"content",
|
|
111
|
+
"contents",
|
|
112
|
+
"history",
|
|
113
|
+
"message",
|
|
114
|
+
"messages",
|
|
115
|
+
"parts",
|
|
116
|
+
"prompt",
|
|
117
|
+
"response",
|
|
118
|
+
"result",
|
|
119
|
+
"stderr",
|
|
120
|
+
"stdout",
|
|
121
|
+
"text",
|
|
122
|
+
"transcript",
|
|
123
|
+
}
|
|
124
|
+
AGY_CONVERSATION_RE = re.compile(
|
|
125
|
+
r"(?m)^[IWEF]\d{4}\s+\S+\s+\d+\s+printmode\.go:\d+\] "
|
|
126
|
+
r"Print mode: conversation=([0-9A-Za-z_-]+), sending message$"
|
|
127
|
+
)
|
|
128
|
+
AGY_MODEL_LABEL_RE = re.compile(
|
|
129
|
+
r'(?m)^[IWEF]\d{4}\s+\S+\s+\d+\s+model_config_manager\.go:\d+\] '
|
|
130
|
+
r'Propagating selected model override to backend: label="([^"]+)"$'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def debug_log(message: str) -> None:
|
|
135
|
+
try:
|
|
136
|
+
state_root = resolve_path(STATE_DIR)
|
|
137
|
+
debug_path = resolve_path(DEBUG_LOG)
|
|
138
|
+
if not path_is_relative_to(debug_path, state_root):
|
|
139
|
+
return
|
|
140
|
+
ensure_private_dir(debug_path.parent)
|
|
141
|
+
append_private_text(debug_path, f"{message}\n")
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def append_private_text(path: Path, text: str) -> None:
|
|
147
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
148
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
149
|
+
flags |= os.O_NOFOLLOW
|
|
150
|
+
fd = os.open(path, flags, 0o600)
|
|
151
|
+
try:
|
|
152
|
+
os.write(fd, text.encode("utf-8"))
|
|
153
|
+
try:
|
|
154
|
+
os.fchmod(fd, 0o600)
|
|
155
|
+
except (OSError, AttributeError):
|
|
156
|
+
pass
|
|
157
|
+
finally:
|
|
158
|
+
os.close(fd)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def redact_for_debug(value: Any) -> Any:
|
|
162
|
+
if isinstance(value, dict):
|
|
163
|
+
redacted: dict[str, Any] = {}
|
|
164
|
+
for key, item in value.items():
|
|
165
|
+
if str(key) in DEBUG_REDACT_KEYS:
|
|
166
|
+
redacted[str(key)] = "[redacted]"
|
|
167
|
+
else:
|
|
168
|
+
redacted[str(key)] = redact_for_debug(item)
|
|
169
|
+
return redacted
|
|
170
|
+
if isinstance(value, list):
|
|
171
|
+
return [redact_for_debug(item) for item in value]
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def send_response(response: dict[str, Any]) -> None:
|
|
176
|
+
global _use_ndjson
|
|
177
|
+
|
|
178
|
+
payload = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
179
|
+
debug_log(f"SEND id={response.get('id')!r} has_error={bool(response.get('error') or response.get('result', {}).get('isError'))}")
|
|
180
|
+
if _use_ndjson:
|
|
181
|
+
sys.stdout.write(payload + b"\n")
|
|
182
|
+
else:
|
|
183
|
+
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("utf-8")
|
|
184
|
+
sys.stdout.write(header + payload)
|
|
185
|
+
sys.stdout.flush()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_message() -> dict[str, Any] | None:
|
|
189
|
+
global _use_ndjson
|
|
190
|
+
|
|
191
|
+
line = sys.stdin.readline()
|
|
192
|
+
if not line:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
line_text = line.decode("utf-8").rstrip("\r\n")
|
|
196
|
+
if line_text.lower().startswith("content-length:"):
|
|
197
|
+
try:
|
|
198
|
+
content_length = int(line_text.split(":", 1)[1].strip())
|
|
199
|
+
except ValueError:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
while True:
|
|
203
|
+
header_line = sys.stdin.readline()
|
|
204
|
+
if not header_line:
|
|
205
|
+
return None
|
|
206
|
+
if header_line in {b"\r\n", b"\n"}:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
body = sys.stdin.read(content_length)
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(body.decode("utf-8"))
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
if line_text.startswith("{") or line_text.startswith("["):
|
|
216
|
+
_use_ndjson = True
|
|
217
|
+
try:
|
|
218
|
+
return json.loads(line_text)
|
|
219
|
+
except json.JSONDecodeError:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def utc_now() -> str:
|
|
226
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def path_is_relative_to(path: Path, parent: Path) -> bool:
|
|
230
|
+
try:
|
|
231
|
+
path.relative_to(parent)
|
|
232
|
+
return True
|
|
233
|
+
except ValueError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_path(path: Path) -> Path:
|
|
238
|
+
return path.expanduser().resolve(strict=False)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def ensure_private_dir(path: Path) -> None:
|
|
242
|
+
resolved = resolve_path(path)
|
|
243
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
244
|
+
if resolved in SHARED_TEMP_DIRS or resolved == resolved.parent:
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
os.chmod(path, 0o700)
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def normalize_safe_id(raw_value: Any, field_name: str) -> tuple[str | None, str | None]:
|
|
253
|
+
value = str(raw_value or "").strip()
|
|
254
|
+
if not value:
|
|
255
|
+
return None, f"{field_name} is required"
|
|
256
|
+
if len(value) > SAFE_ID_MAX_LEN:
|
|
257
|
+
return None, f"{field_name} must be at most {SAFE_ID_MAX_LEN} characters"
|
|
258
|
+
if not SAFE_ID_RE.fullmatch(value):
|
|
259
|
+
return None, f"{field_name} must match ^[0-9A-Za-z_-]+$"
|
|
260
|
+
return value, None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def confined_state_file(base_dir: Path, identifier: str, field_name: str) -> Path:
|
|
264
|
+
safe_id, error = normalize_safe_id(identifier, field_name)
|
|
265
|
+
if error or safe_id is None:
|
|
266
|
+
raise ValueError(error or f"invalid {field_name}")
|
|
267
|
+
|
|
268
|
+
base = resolve_path(base_dir)
|
|
269
|
+
path = resolve_path(base / f"{safe_id}.json")
|
|
270
|
+
if not path_is_relative_to(path, base):
|
|
271
|
+
raise ValueError(f"{field_name} escapes the state directory")
|
|
272
|
+
return path
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def open_confined_read_fd(path: Path, root: Path) -> int | None:
|
|
276
|
+
resolved_root = resolve_path(root)
|
|
277
|
+
resolved_path = resolve_path(path)
|
|
278
|
+
if not path_is_relative_to(resolved_path, resolved_root):
|
|
279
|
+
return None
|
|
280
|
+
flags = os.O_RDONLY
|
|
281
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
282
|
+
flags |= os.O_CLOEXEC
|
|
283
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
284
|
+
flags |= os.O_NOFOLLOW
|
|
285
|
+
try:
|
|
286
|
+
fd = os.open(resolved_path, flags)
|
|
287
|
+
except OSError:
|
|
288
|
+
return None
|
|
289
|
+
try:
|
|
290
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
291
|
+
os.close(fd)
|
|
292
|
+
return None
|
|
293
|
+
fd_path = resolved_fd_path(fd)
|
|
294
|
+
if fd_path is None or not path_is_relative_to(fd_path, resolved_root):
|
|
295
|
+
os.close(fd)
|
|
296
|
+
return None
|
|
297
|
+
return fd
|
|
298
|
+
except OSError:
|
|
299
|
+
os.close(fd)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def resolved_fd_path(fd: int) -> Path | None:
|
|
304
|
+
for fd_root in (Path("/proc/self/fd"), Path("/dev/fd")):
|
|
305
|
+
fd_link = fd_root / str(fd)
|
|
306
|
+
if fd_link.exists():
|
|
307
|
+
try:
|
|
308
|
+
return resolve_path(Path(os.readlink(fd_link)))
|
|
309
|
+
except OSError:
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
import fcntl # type: ignore[import-not-found]
|
|
313
|
+
except ImportError:
|
|
314
|
+
return None
|
|
315
|
+
f_getpath = getattr(fcntl, "F_GETPATH", None)
|
|
316
|
+
if f_getpath is None:
|
|
317
|
+
return None
|
|
318
|
+
try:
|
|
319
|
+
raw_path = fcntl.fcntl(fd, f_getpath, b"\0" * 4096)
|
|
320
|
+
except OSError:
|
|
321
|
+
return None
|
|
322
|
+
if isinstance(raw_path, bytes):
|
|
323
|
+
path_text = raw_path.split(b"\0", 1)[0].decode("utf-8", errors="replace")
|
|
324
|
+
else:
|
|
325
|
+
path_text = str(raw_path).split("\0", 1)[0]
|
|
326
|
+
return resolve_path(Path(path_text)) if path_text else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def read_text_confined(path: Path, root: Path) -> str | None:
|
|
330
|
+
result = read_text_confined_with_stat(path, root)
|
|
331
|
+
return result[0] if result is not None else None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def read_text_confined_with_stat(path: Path, root: Path) -> tuple[str, os.stat_result] | None:
|
|
335
|
+
fd = open_confined_read_fd(path, root)
|
|
336
|
+
if fd is None:
|
|
337
|
+
return None
|
|
338
|
+
with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as fh:
|
|
339
|
+
file_stat = os.fstat(fh.fileno())
|
|
340
|
+
return fh.read(), file_stat
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_bytes_confined(path: Path, root: Path) -> bytes | None:
|
|
344
|
+
fd = open_confined_read_fd(path, root)
|
|
345
|
+
if fd is None:
|
|
346
|
+
return None
|
|
347
|
+
with os.fdopen(fd, "rb") as fh:
|
|
348
|
+
return fh.read()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def normalize_model_name(raw_value: Any, field_name: str = "model") -> tuple[str | None, str | None]:
|
|
352
|
+
if raw_value is None:
|
|
353
|
+
return None, None
|
|
354
|
+
value = str(raw_value).strip()
|
|
355
|
+
if not value:
|
|
356
|
+
return None, None
|
|
357
|
+
if "\x00" in value:
|
|
358
|
+
return None, f"{field_name} must not contain NUL bytes"
|
|
359
|
+
if value.startswith("-"):
|
|
360
|
+
return None, f"{field_name} must not start with '-'"
|
|
361
|
+
return value, None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def ensure_no_nul(value: str, field_name: str) -> str | None:
|
|
365
|
+
if "\x00" in value:
|
|
366
|
+
return f"{field_name} must not contain NUL bytes"
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def select_model_name(*values: Any) -> tuple[str | None, str | None]:
|
|
371
|
+
for value in values:
|
|
372
|
+
selected, error = normalize_model_name(value)
|
|
373
|
+
if error:
|
|
374
|
+
return None, error
|
|
375
|
+
if selected:
|
|
376
|
+
return selected, None
|
|
377
|
+
return None, None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def is_gemini_model_name(model_name: str | None) -> bool:
|
|
381
|
+
if not model_name:
|
|
382
|
+
return False
|
|
383
|
+
normalized = model_name.strip()
|
|
384
|
+
return bool(GEMINI_MODEL_RE.fullmatch(normalized) or GEMINI_LABEL_RE.fullmatch(normalized))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def require_gemini_model(model_name: str | None, backend_name: str) -> str | None:
|
|
388
|
+
if not is_gemini_model_name(model_name):
|
|
389
|
+
shown = model_name or "unknown"
|
|
390
|
+
return f"{backend_name} reviewer model must be a Gemini family model, got: {shown}"
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def gemini_api_model_path(model_name: str) -> str:
|
|
395
|
+
if model_name.startswith(("models/", "publishers/")):
|
|
396
|
+
return model_name
|
|
397
|
+
return f"models/{model_name}"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
401
|
+
ensure_private_dir(path.parent)
|
|
402
|
+
temp_path = path.with_suffix(path.suffix + ".tmp")
|
|
403
|
+
temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
404
|
+
try:
|
|
405
|
+
os.chmod(temp_path, 0o600)
|
|
406
|
+
except OSError:
|
|
407
|
+
pass
|
|
408
|
+
temp_path.replace(path)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
412
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_private_env_file(env_file: Path | None = None) -> list[str]:
|
|
416
|
+
target = env_file or (Path.home() / ".gemini" / ".env")
|
|
417
|
+
lines = read_private_env_lines(target)
|
|
418
|
+
if lines is None:
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
loaded: list[str] = []
|
|
422
|
+
for raw_line in lines:
|
|
423
|
+
line = raw_line.strip()
|
|
424
|
+
if not line or line.startswith("#"):
|
|
425
|
+
continue
|
|
426
|
+
if line.startswith("export "):
|
|
427
|
+
line = line[len("export ") :].strip()
|
|
428
|
+
if "=" not in line:
|
|
429
|
+
continue
|
|
430
|
+
key, value = line.split("=", 1)
|
|
431
|
+
key = key.strip()
|
|
432
|
+
value = value.strip()
|
|
433
|
+
if key not in PRIVATE_ENV_KEYS:
|
|
434
|
+
continue
|
|
435
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
436
|
+
value = value[1:-1]
|
|
437
|
+
if key not in os.environ:
|
|
438
|
+
os.environ[key] = value
|
|
439
|
+
loaded.append(key)
|
|
440
|
+
return loaded
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def read_private_env_lines(path: Path) -> list[str] | None:
|
|
444
|
+
target = path.expanduser()
|
|
445
|
+
flags = os.O_RDONLY
|
|
446
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
447
|
+
flags |= os.O_CLOEXEC
|
|
448
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
449
|
+
flags |= os.O_NOFOLLOW
|
|
450
|
+
try:
|
|
451
|
+
stat_before = os.lstat(target)
|
|
452
|
+
if stat.S_ISLNK(stat_before.st_mode) or not stat.S_ISREG(stat_before.st_mode):
|
|
453
|
+
return None
|
|
454
|
+
if stat_before.st_mode & 0o022:
|
|
455
|
+
return None
|
|
456
|
+
if hasattr(os, "getuid") and stat_before.st_uid != os.getuid():
|
|
457
|
+
return None
|
|
458
|
+
fd = os.open(target, flags)
|
|
459
|
+
except OSError:
|
|
460
|
+
return None
|
|
461
|
+
try:
|
|
462
|
+
stat_after = os.fstat(fd)
|
|
463
|
+
if not stat.S_ISREG(stat_after.st_mode) or stat_after.st_mode & 0o022:
|
|
464
|
+
return None
|
|
465
|
+
if hasattr(os, "getuid") and stat_after.st_uid != os.getuid():
|
|
466
|
+
return None
|
|
467
|
+
if (stat_before.st_dev, stat_before.st_ino) != (stat_after.st_dev, stat_after.st_ino):
|
|
468
|
+
return None
|
|
469
|
+
with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as fh:
|
|
470
|
+
fd = -1
|
|
471
|
+
return fh.read().splitlines()
|
|
472
|
+
finally:
|
|
473
|
+
if fd >= 0:
|
|
474
|
+
os.close(fd)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def normalize_image_paths(raw_value: Any) -> tuple[list[str], str | None]:
|
|
478
|
+
if raw_value is None:
|
|
479
|
+
return [], None
|
|
480
|
+
if isinstance(raw_value, str):
|
|
481
|
+
candidate = raw_value.strip()
|
|
482
|
+
return ([candidate] if candidate else []), None
|
|
483
|
+
if not isinstance(raw_value, list):
|
|
484
|
+
return [], "imagePaths must be a string or an array of strings"
|
|
485
|
+
|
|
486
|
+
image_paths: list[str] = []
|
|
487
|
+
for item in raw_value:
|
|
488
|
+
if not isinstance(item, str):
|
|
489
|
+
return [], "imagePaths entries must be strings"
|
|
490
|
+
candidate = item.strip()
|
|
491
|
+
if candidate:
|
|
492
|
+
image_paths.append(candidate)
|
|
493
|
+
return image_paths, None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def build_inline_image_parts(image_paths: list[str]) -> tuple[list[dict[str, Any]], str | None]:
|
|
497
|
+
parts: list[dict[str, Any]] = []
|
|
498
|
+
workspace_root = resolve_path(WORKSPACE_ROOT)
|
|
499
|
+
for raw_path in image_paths:
|
|
500
|
+
path = Path(raw_path).expanduser()
|
|
501
|
+
if not path.is_absolute():
|
|
502
|
+
path = workspace_root / path
|
|
503
|
+
resolved_path = resolve_path(path)
|
|
504
|
+
if not path_is_relative_to(resolved_path, workspace_root):
|
|
505
|
+
return [], f"image file must stay under workspace root: {raw_path}"
|
|
506
|
+
mime_type, _ = mimetypes.guess_type(resolved_path.name)
|
|
507
|
+
if not mime_type or not mime_type.startswith("image/"):
|
|
508
|
+
return [], f"unsupported image type for Gemini review: {raw_path}"
|
|
509
|
+
data = read_bytes_confined(resolved_path, workspace_root)
|
|
510
|
+
if data is None:
|
|
511
|
+
return [], f"image file not found or not confined to workspace root: {raw_path}"
|
|
512
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
513
|
+
parts.append({"inlineData": {"mimeType": mime_type, "data": encoded}})
|
|
514
|
+
return parts, None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def find_gemini_bin() -> str | None:
|
|
518
|
+
if Path(GEMINI_BIN).is_file():
|
|
519
|
+
return GEMINI_BIN
|
|
520
|
+
return shutil.which(GEMINI_BIN)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def find_agy_bin() -> str | None:
|
|
524
|
+
if Path(AGY_BIN).is_file():
|
|
525
|
+
return AGY_BIN
|
|
526
|
+
return shutil.which(AGY_BIN)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def resolve_backend(preferred_backend: str | None) -> str:
|
|
530
|
+
backend = preferred_backend or DEFAULT_BACKEND
|
|
531
|
+
if backend not in {"auto", "api", "cli", "agy"}:
|
|
532
|
+
raise ValueError(f"unsupported Gemini backend: {backend}")
|
|
533
|
+
if backend == "auto":
|
|
534
|
+
return "api" if get_api_key() else "cli"
|
|
535
|
+
return backend
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def get_api_key() -> str | None:
|
|
539
|
+
return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parse_gemini_json(raw_stdout: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
543
|
+
lines = [line.strip() for line in raw_stdout.splitlines() if line.strip()]
|
|
544
|
+
if not lines:
|
|
545
|
+
return None, "Gemini CLI returned empty output"
|
|
546
|
+
|
|
547
|
+
for candidate in reversed(lines):
|
|
548
|
+
try:
|
|
549
|
+
payload = json.loads(candidate)
|
|
550
|
+
except json.JSONDecodeError:
|
|
551
|
+
continue
|
|
552
|
+
if isinstance(payload, dict):
|
|
553
|
+
return payload, None
|
|
554
|
+
|
|
555
|
+
return None, "Gemini CLI did not return JSON output"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def extract_cli_error_message(raw_stdout: str, raw_stderr: str) -> str:
|
|
559
|
+
for text in (raw_stdout, raw_stderr):
|
|
560
|
+
stripped = text.strip()
|
|
561
|
+
if not stripped:
|
|
562
|
+
continue
|
|
563
|
+
try:
|
|
564
|
+
payload = json.loads(stripped)
|
|
565
|
+
except json.JSONDecodeError:
|
|
566
|
+
return stripped
|
|
567
|
+
if not isinstance(payload, dict):
|
|
568
|
+
return stripped
|
|
569
|
+
error = payload.get("error")
|
|
570
|
+
if isinstance(error, dict):
|
|
571
|
+
message = error.get("message")
|
|
572
|
+
if isinstance(message, str) and message.strip():
|
|
573
|
+
return message.strip()
|
|
574
|
+
response = payload.get("response")
|
|
575
|
+
if isinstance(response, str) and response.strip():
|
|
576
|
+
return response.strip()
|
|
577
|
+
return stripped
|
|
578
|
+
return "unknown error"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def agy_output_is_error(text: str) -> bool:
|
|
582
|
+
stripped = text.strip()
|
|
583
|
+
return stripped == "Error: timed out waiting for response" or stripped.startswith("Error: timed out waiting for response")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def extract_agy_conversation_id_from_log(log_path: Path) -> str | None:
|
|
587
|
+
conversation_ids = extract_agy_conversation_ids_from_log(log_path)
|
|
588
|
+
return conversation_ids[-1] if conversation_ids else None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def extract_agy_conversation_ids_from_log(log_path: Path) -> list[str]:
|
|
592
|
+
try:
|
|
593
|
+
text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
594
|
+
except OSError:
|
|
595
|
+
return []
|
|
596
|
+
matches = [match.group(1) for match in AGY_CONVERSATION_RE.finditer(text)]
|
|
597
|
+
conversation_ids: list[str] = []
|
|
598
|
+
for match in matches:
|
|
599
|
+
conversation_id, error = normalize_safe_id(match, "conversationId")
|
|
600
|
+
if not error and conversation_id and conversation_id not in conversation_ids:
|
|
601
|
+
conversation_ids.append(conversation_id)
|
|
602
|
+
return conversation_ids
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def agy_conversation_position(log_text: str, conversation_id: str) -> int | None:
|
|
606
|
+
position: int | None = None
|
|
607
|
+
for match in AGY_CONVERSATION_RE.finditer(log_text):
|
|
608
|
+
if match.group(1) == conversation_id:
|
|
609
|
+
position = match.start()
|
|
610
|
+
return position
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def agy_conversation_root(conversation_id: str) -> Path | None:
|
|
614
|
+
safe_id, error = normalize_safe_id(conversation_id, "conversationId")
|
|
615
|
+
if error or safe_id is None:
|
|
616
|
+
return None
|
|
617
|
+
brain_root = resolve_path(AGY_APP_DATA_DIR / "brain")
|
|
618
|
+
conversation_root = resolve_path(brain_root / safe_id)
|
|
619
|
+
if not path_is_relative_to(conversation_root, brain_root):
|
|
620
|
+
return None
|
|
621
|
+
return conversation_root
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def agy_transcript_paths(conversation_id: str) -> list[Path]:
|
|
625
|
+
conversation_root = agy_conversation_root(conversation_id)
|
|
626
|
+
if conversation_root is None:
|
|
627
|
+
return []
|
|
628
|
+
logs_dir = conversation_root / ".system_generated" / "logs"
|
|
629
|
+
return [logs_dir / "transcript_full.jsonl", logs_dir / "transcript.jsonl"]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def agy_artifact_text(
|
|
633
|
+
path: Path,
|
|
634
|
+
*,
|
|
635
|
+
conversation_root: Path,
|
|
636
|
+
artifact_min_mtime: float | None = None,
|
|
637
|
+
) -> str | None:
|
|
638
|
+
if path.suffix.lower() not in {".md", ".markdown", ".txt", ".json", ".yaml", ".yml"}:
|
|
639
|
+
return None
|
|
640
|
+
result = read_text_confined_with_stat(path, conversation_root)
|
|
641
|
+
if result is None:
|
|
642
|
+
return None
|
|
643
|
+
text, file_stat = result
|
|
644
|
+
if artifact_min_mtime is not None and file_stat.st_mtime < artifact_min_mtime - 1.0:
|
|
645
|
+
return None
|
|
646
|
+
text = text.strip()
|
|
647
|
+
if not text:
|
|
648
|
+
return None
|
|
649
|
+
if len(text) > AGY_ARTIFACT_MAX_CHARS:
|
|
650
|
+
return text[:AGY_ARTIFACT_MAX_CHARS] + "\n\n[truncated by gemini-review agy artifact fallback]"
|
|
651
|
+
return text
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def extract_file_uri_paths(text: str) -> list[Path]:
|
|
655
|
+
paths: list[Path] = []
|
|
656
|
+
for match in re.finditer(r"file://[^\s)>\]]+", text):
|
|
657
|
+
parsed = urllib.parse.urlparse(match.group(0))
|
|
658
|
+
if parsed.scheme != "file" or not parsed.path:
|
|
659
|
+
continue
|
|
660
|
+
paths.append(Path(urllib.parse.unquote(parsed.path)))
|
|
661
|
+
return paths
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def strip_file_uri_refs(text: str) -> str:
|
|
665
|
+
return re.sub(r"file://[^\s)>\]]+", "", text).strip(" \t\r\n.,:;()[]<>")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def collect_model_candidates(value: Any, candidates: list[str], *, under_untrusted_text: bool = False) -> None:
|
|
669
|
+
if isinstance(value, dict):
|
|
670
|
+
for key, item in value.items():
|
|
671
|
+
normalized_key = MODEL_KEY_RE.sub("", str(key).lower())
|
|
672
|
+
next_under_untrusted = under_untrusted_text or normalized_key in MODEL_UNTRUSTED_CONTAINER_KEYS
|
|
673
|
+
if (
|
|
674
|
+
not under_untrusted_text
|
|
675
|
+
and normalized_key in MODEL_KEY_NAMES
|
|
676
|
+
and isinstance(item, str)
|
|
677
|
+
and item.strip()
|
|
678
|
+
):
|
|
679
|
+
candidates.append(item.strip())
|
|
680
|
+
collect_model_candidates(item, candidates, under_untrusted_text=next_under_untrusted)
|
|
681
|
+
elif isinstance(value, list):
|
|
682
|
+
for item in value:
|
|
683
|
+
collect_model_candidates(item, candidates, under_untrusted_text=under_untrusted_text)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def select_model_candidate(candidates: list[str]) -> tuple[str | None, str | None]:
|
|
687
|
+
cleaned = [candidate.strip().strip('",') for candidate in candidates if candidate.strip()]
|
|
688
|
+
if not cleaned:
|
|
689
|
+
return None, None
|
|
690
|
+
normalized = {candidate.lower() for candidate in cleaned}
|
|
691
|
+
if len(normalized) > 1:
|
|
692
|
+
return None, f"conflicting Antigravity model candidates: {', '.join(cleaned)}"
|
|
693
|
+
return cleaned[-1], None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def collect_model_candidates_from_agy_log(text: str, candidates: list[str]) -> None:
|
|
697
|
+
matches = [match.group(1) for match in AGY_MODEL_LABEL_RE.finditer(text)]
|
|
698
|
+
candidates.extend(match.strip() for match in matches if match.strip())
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def transcript_step_is_user_event(step: dict[str, Any]) -> bool:
|
|
702
|
+
source = str(step.get("source", "")).upper()
|
|
703
|
+
role = str(step.get("role", "")).lower()
|
|
704
|
+
step_type = str(step.get("type", "")).upper()
|
|
705
|
+
author = str(step.get("author", "")).lower()
|
|
706
|
+
return (
|
|
707
|
+
source in {"USER", "USER_EXPLICIT", "USER_IMPLICIT"}
|
|
708
|
+
or role == "user"
|
|
709
|
+
or author == "user"
|
|
710
|
+
or step_type in {"USER_INPUT", "USER_MESSAGE"}
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def transcript_step_contains_nonce(step: dict[str, Any], invocation_nonce: str) -> bool:
|
|
715
|
+
content = step.get("content")
|
|
716
|
+
if isinstance(content, str):
|
|
717
|
+
return invocation_nonce in content
|
|
718
|
+
if isinstance(content, list):
|
|
719
|
+
return any(isinstance(item, str) and invocation_nonce in item for item in content)
|
|
720
|
+
if isinstance(content, dict):
|
|
721
|
+
return invocation_nonce in json.dumps(content, ensure_ascii=False)
|
|
722
|
+
return False
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def transcript_lines_at_or_after_nonce(transcript_text: str, invocation_nonce: str | None) -> list[str] | None:
|
|
726
|
+
if not invocation_nonce:
|
|
727
|
+
return None
|
|
728
|
+
lines = transcript_text.splitlines()
|
|
729
|
+
for index, line in enumerate(lines):
|
|
730
|
+
try:
|
|
731
|
+
step = json.loads(line)
|
|
732
|
+
except json.JSONDecodeError:
|
|
733
|
+
continue
|
|
734
|
+
if (
|
|
735
|
+
isinstance(step, dict)
|
|
736
|
+
and transcript_step_is_user_event(step)
|
|
737
|
+
and transcript_step_contains_nonce(step, invocation_nonce)
|
|
738
|
+
):
|
|
739
|
+
return lines[index:]
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def transcript_contains_nonce(transcript_text: str, invocation_nonce: str) -> bool:
|
|
744
|
+
return transcript_lines_at_or_after_nonce(transcript_text, invocation_nonce) is not None
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def agy_conversation_has_nonce(conversation_id: str, invocation_nonce: str) -> bool:
|
|
748
|
+
conversation_root = agy_conversation_root(conversation_id)
|
|
749
|
+
if conversation_root is None:
|
|
750
|
+
return False
|
|
751
|
+
for transcript_path in agy_transcript_paths(conversation_id):
|
|
752
|
+
transcript_text = read_text_confined(transcript_path, conversation_root)
|
|
753
|
+
if transcript_text is not None and transcript_contains_nonce(transcript_text, invocation_nonce):
|
|
754
|
+
return True
|
|
755
|
+
return False
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def select_agy_conversation_id_from_state(
|
|
759
|
+
log_path: Path,
|
|
760
|
+
*,
|
|
761
|
+
invocation_nonce: str | None = None,
|
|
762
|
+
) -> tuple[str | None, str | None]:
|
|
763
|
+
if not invocation_nonce:
|
|
764
|
+
return None, "invocation_nonce is required for Antigravity transcript recovery"
|
|
765
|
+
conversation_ids = extract_agy_conversation_ids_from_log(log_path)
|
|
766
|
+
if not conversation_ids:
|
|
767
|
+
return None, "could not locate Antigravity conversation id in this invocation's log"
|
|
768
|
+
for conversation_id in reversed(conversation_ids):
|
|
769
|
+
if agy_conversation_has_nonce(conversation_id, invocation_nonce):
|
|
770
|
+
return conversation_id, None
|
|
771
|
+
return None, f"Antigravity transcript is not bound to this invocation: {conversation_ids[-1]}"
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def extract_agy_model_from_state(
|
|
775
|
+
log_path: Path,
|
|
776
|
+
*,
|
|
777
|
+
conversation_id: str,
|
|
778
|
+
invocation_nonce: str | None = None,
|
|
779
|
+
) -> tuple[str | None, str | None]:
|
|
780
|
+
if not invocation_nonce:
|
|
781
|
+
return None, "invocation_nonce is required for Antigravity model provenance"
|
|
782
|
+
candidates: list[str] = []
|
|
783
|
+
try:
|
|
784
|
+
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
785
|
+
except OSError:
|
|
786
|
+
log_text = ""
|
|
787
|
+
conversation_position = agy_conversation_position(log_text, conversation_id)
|
|
788
|
+
if conversation_position is not None:
|
|
789
|
+
collect_model_candidates_from_agy_log(log_text[:conversation_position], candidates)
|
|
790
|
+
|
|
791
|
+
conversation_root = agy_conversation_root(conversation_id)
|
|
792
|
+
if conversation_root is not None:
|
|
793
|
+
for transcript_path in agy_transcript_paths(conversation_id):
|
|
794
|
+
transcript_text = read_text_confined(transcript_path, conversation_root)
|
|
795
|
+
if transcript_text is None:
|
|
796
|
+
continue
|
|
797
|
+
scoped_lines = transcript_lines_at_or_after_nonce(transcript_text, invocation_nonce)
|
|
798
|
+
if scoped_lines is None:
|
|
799
|
+
continue
|
|
800
|
+
for line in scoped_lines:
|
|
801
|
+
try:
|
|
802
|
+
step = json.loads(line)
|
|
803
|
+
except json.JSONDecodeError:
|
|
804
|
+
continue
|
|
805
|
+
collect_model_candidates(step, candidates)
|
|
806
|
+
|
|
807
|
+
return select_model_candidate(candidates)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def extract_agy_response_from_transcript(
|
|
811
|
+
conversation_id: str,
|
|
812
|
+
*,
|
|
813
|
+
invocation_nonce: str | None = None,
|
|
814
|
+
artifact_min_mtime: float | None = None,
|
|
815
|
+
) -> str | None:
|
|
816
|
+
if not invocation_nonce:
|
|
817
|
+
return None
|
|
818
|
+
conversation_root = agy_conversation_root(conversation_id)
|
|
819
|
+
if conversation_root is None:
|
|
820
|
+
return None
|
|
821
|
+
for transcript_path in agy_transcript_paths(conversation_id):
|
|
822
|
+
transcript_text = read_text_confined(transcript_path, conversation_root)
|
|
823
|
+
if transcript_text is None:
|
|
824
|
+
continue
|
|
825
|
+
scoped_lines = transcript_lines_at_or_after_nonce(transcript_text, invocation_nonce)
|
|
826
|
+
if scoped_lines is None:
|
|
827
|
+
continue
|
|
828
|
+
final_response: str | None = None
|
|
829
|
+
artifact_paths: list[Path] = []
|
|
830
|
+
for line in scoped_lines:
|
|
831
|
+
try:
|
|
832
|
+
step = json.loads(line)
|
|
833
|
+
except json.JSONDecodeError:
|
|
834
|
+
continue
|
|
835
|
+
if step.get("source") != "MODEL":
|
|
836
|
+
continue
|
|
837
|
+
step_type = step.get("type")
|
|
838
|
+
content = step.get("content")
|
|
839
|
+
if step_type == "PLANNER_RESPONSE" and isinstance(content, str) and content.strip():
|
|
840
|
+
final_response = content.strip()
|
|
841
|
+
artifact_paths = extract_file_uri_paths(content)
|
|
842
|
+
|
|
843
|
+
if final_response:
|
|
844
|
+
if strip_file_uri_refs(final_response):
|
|
845
|
+
return final_response
|
|
846
|
+
for path in reversed(artifact_paths):
|
|
847
|
+
artifact = agy_artifact_text(
|
|
848
|
+
path,
|
|
849
|
+
conversation_root=conversation_root,
|
|
850
|
+
artifact_min_mtime=artifact_min_mtime,
|
|
851
|
+
)
|
|
852
|
+
if artifact:
|
|
853
|
+
return artifact
|
|
854
|
+
return None
|
|
855
|
+
return None
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def extract_agy_response_from_state(
|
|
859
|
+
log_path: Path,
|
|
860
|
+
*,
|
|
861
|
+
invocation_nonce: str | None = None,
|
|
862
|
+
artifact_min_mtime: float | None = None,
|
|
863
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
864
|
+
if not invocation_nonce:
|
|
865
|
+
return None, "invocation_nonce is required for Antigravity transcript recovery", None
|
|
866
|
+
conversation_id, conversation_error = select_agy_conversation_id_from_state(
|
|
867
|
+
log_path,
|
|
868
|
+
invocation_nonce=invocation_nonce,
|
|
869
|
+
)
|
|
870
|
+
if conversation_error or conversation_id is None:
|
|
871
|
+
return None, conversation_error or "could not locate Antigravity conversation id", None
|
|
872
|
+
response = extract_agy_response_from_transcript(
|
|
873
|
+
conversation_id,
|
|
874
|
+
invocation_nonce=invocation_nonce,
|
|
875
|
+
artifact_min_mtime=artifact_min_mtime,
|
|
876
|
+
)
|
|
877
|
+
if response:
|
|
878
|
+
debug_log(f"AGY_TRANSCRIPT_RECOVERED conversation={conversation_id}")
|
|
879
|
+
return response, None, conversation_id
|
|
880
|
+
return None, f"Antigravity transcript has no final response yet: {conversation_id}", None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def extract_api_response_text(payload: dict[str, Any]) -> str:
|
|
884
|
+
candidates = payload.get("candidates")
|
|
885
|
+
if isinstance(candidates, list):
|
|
886
|
+
for candidate in candidates:
|
|
887
|
+
if not isinstance(candidate, dict):
|
|
888
|
+
continue
|
|
889
|
+
content = candidate.get("content")
|
|
890
|
+
if not isinstance(content, dict):
|
|
891
|
+
continue
|
|
892
|
+
parts = content.get("parts")
|
|
893
|
+
if not isinstance(parts, list):
|
|
894
|
+
continue
|
|
895
|
+
texts: list[str] = []
|
|
896
|
+
for part in parts:
|
|
897
|
+
if isinstance(part, dict):
|
|
898
|
+
text = part.get("text")
|
|
899
|
+
if isinstance(text, str):
|
|
900
|
+
texts.append(text)
|
|
901
|
+
if texts:
|
|
902
|
+
return "\n".join(texts).strip()
|
|
903
|
+
|
|
904
|
+
prompt_feedback = payload.get("promptFeedback")
|
|
905
|
+
if isinstance(prompt_feedback, dict):
|
|
906
|
+
block_reason = prompt_feedback.get("blockReason")
|
|
907
|
+
if isinstance(block_reason, str) and block_reason:
|
|
908
|
+
raise ValueError(f"Gemini API response blocked: {block_reason}")
|
|
909
|
+
|
|
910
|
+
raise ValueError("Gemini API response does not contain candidate text")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def job_state_path(job_id: str) -> Path:
|
|
914
|
+
return confined_state_file(JOBS_DIR, job_id, "jobId")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def thread_state_path(thread_id: str) -> Path:
|
|
918
|
+
return confined_state_file(THREADS_DIR, thread_id, "threadId")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def is_pid_alive(pid: int | None) -> bool:
|
|
922
|
+
if not pid or pid <= 0:
|
|
923
|
+
return False
|
|
924
|
+
try:
|
|
925
|
+
os.kill(pid, 0)
|
|
926
|
+
except OSError:
|
|
927
|
+
return False
|
|
928
|
+
return True
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def kill_process_tree(process: subprocess.Popen[str]) -> None:
|
|
932
|
+
try:
|
|
933
|
+
if os.name == "nt":
|
|
934
|
+
subprocess.run(
|
|
935
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
936
|
+
stdin=subprocess.DEVNULL,
|
|
937
|
+
stdout=subprocess.DEVNULL,
|
|
938
|
+
stderr=subprocess.DEVNULL,
|
|
939
|
+
timeout=5,
|
|
940
|
+
check=False,
|
|
941
|
+
)
|
|
942
|
+
if process.poll() is None:
|
|
943
|
+
process.kill()
|
|
944
|
+
else:
|
|
945
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
946
|
+
except OSError:
|
|
947
|
+
try:
|
|
948
|
+
process.kill()
|
|
949
|
+
except OSError:
|
|
950
|
+
pass
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def run_process_tree(
|
|
954
|
+
cmd: list[str],
|
|
955
|
+
*,
|
|
956
|
+
timeout_sec: int,
|
|
957
|
+
) -> tuple[subprocess.CompletedProcess[str] | None, str | None, int]:
|
|
958
|
+
started = time.monotonic()
|
|
959
|
+
popen_kwargs: dict[str, Any] = {
|
|
960
|
+
"stdout": subprocess.PIPE,
|
|
961
|
+
"stderr": subprocess.PIPE,
|
|
962
|
+
"text": True,
|
|
963
|
+
"stdin": subprocess.DEVNULL,
|
|
964
|
+
"close_fds": True,
|
|
965
|
+
}
|
|
966
|
+
if os.name == "nt":
|
|
967
|
+
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
968
|
+
else:
|
|
969
|
+
popen_kwargs["start_new_session"] = True
|
|
970
|
+
|
|
971
|
+
process = subprocess.Popen(cmd, **popen_kwargs)
|
|
972
|
+
try:
|
|
973
|
+
stdout, stderr = process.communicate(timeout=timeout_sec)
|
|
974
|
+
except subprocess.TimeoutExpired:
|
|
975
|
+
kill_process_tree(process)
|
|
976
|
+
try:
|
|
977
|
+
stdout, stderr = process.communicate(timeout=5)
|
|
978
|
+
except subprocess.TimeoutExpired:
|
|
979
|
+
return None, f"process tree did not exit after timeout ({timeout_sec} seconds)", int(
|
|
980
|
+
(time.monotonic() - started) * 1000
|
|
981
|
+
)
|
|
982
|
+
return None, f"timed out after {timeout_sec} seconds", int((time.monotonic() - started) * 1000)
|
|
983
|
+
|
|
984
|
+
return subprocess.CompletedProcess(cmd, process.returncode, stdout, stderr), None, int(
|
|
985
|
+
(time.monotonic() - started) * 1000
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def serialize_job(job: dict[str, Any]) -> dict[str, Any]:
|
|
990
|
+
result = job.get("result") or {}
|
|
991
|
+
payload = {
|
|
992
|
+
"jobId": job.get("jobId"),
|
|
993
|
+
"status": job.get("status"),
|
|
994
|
+
"done": job.get("status") in TERMINAL_JOB_STATES,
|
|
995
|
+
"threadId": result.get("threadId"),
|
|
996
|
+
"response": result.get("response"),
|
|
997
|
+
"model": result.get("model"),
|
|
998
|
+
"backend": result.get("backend"),
|
|
999
|
+
"duration_ms": result.get("duration_ms"),
|
|
1000
|
+
"stop_reason": result.get("stop_reason"),
|
|
1001
|
+
"error": job.get("error"),
|
|
1002
|
+
"createdAt": job.get("createdAt"),
|
|
1003
|
+
"startedAt": job.get("startedAt"),
|
|
1004
|
+
"completedAt": job.get("completedAt"),
|
|
1005
|
+
"updatedAt": job.get("updatedAt"),
|
|
1006
|
+
"resumeHint": "Call review_status with this jobId until done=true.",
|
|
1007
|
+
}
|
|
1008
|
+
for optional_key in ("model_provenance", "requested_model", "warning"):
|
|
1009
|
+
if optional_key in result:
|
|
1010
|
+
payload[optional_key] = result.get(optional_key)
|
|
1011
|
+
return payload
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def load_thread_history(thread_id: str) -> list[dict[str, str]]:
|
|
1015
|
+
path = thread_state_path(thread_id)
|
|
1016
|
+
if not path.exists():
|
|
1017
|
+
return []
|
|
1018
|
+
payload = read_json(path)
|
|
1019
|
+
history = payload.get("history")
|
|
1020
|
+
if not isinstance(history, list):
|
|
1021
|
+
return []
|
|
1022
|
+
result: list[dict[str, str]] = []
|
|
1023
|
+
for item in history:
|
|
1024
|
+
if not isinstance(item, dict):
|
|
1025
|
+
continue
|
|
1026
|
+
role = str(item.get("role", "")).strip()
|
|
1027
|
+
text = str(item.get("text", "")).strip()
|
|
1028
|
+
if role in {"user", "model"} and text:
|
|
1029
|
+
result.append({"role": role, "text": text})
|
|
1030
|
+
return result
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def save_thread_history(
|
|
1034
|
+
*,
|
|
1035
|
+
thread_id: str,
|
|
1036
|
+
history: list[dict[str, str]],
|
|
1037
|
+
model: str,
|
|
1038
|
+
backend: str,
|
|
1039
|
+
) -> None:
|
|
1040
|
+
now = utc_now()
|
|
1041
|
+
path = thread_state_path(thread_id)
|
|
1042
|
+
created_at = now
|
|
1043
|
+
if path.exists():
|
|
1044
|
+
existing = read_json(path)
|
|
1045
|
+
created_at = str(existing.get("createdAt") or now)
|
|
1046
|
+
write_json(
|
|
1047
|
+
path,
|
|
1048
|
+
{
|
|
1049
|
+
"threadId": thread_id,
|
|
1050
|
+
"createdAt": created_at,
|
|
1051
|
+
"updatedAt": now,
|
|
1052
|
+
"model": model,
|
|
1053
|
+
"backend": backend,
|
|
1054
|
+
"history": history,
|
|
1055
|
+
},
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def build_cli_prompt(
|
|
1060
|
+
prompt: str,
|
|
1061
|
+
*,
|
|
1062
|
+
history: list[dict[str, str]],
|
|
1063
|
+
system: str | None,
|
|
1064
|
+
) -> str:
|
|
1065
|
+
selected_system = (system or DEFAULT_SYSTEM).strip()
|
|
1066
|
+
if not history and not selected_system:
|
|
1067
|
+
return prompt
|
|
1068
|
+
|
|
1069
|
+
sections: list[str] = []
|
|
1070
|
+
if selected_system:
|
|
1071
|
+
sections.extend(["## System Instructions", selected_system, ""])
|
|
1072
|
+
if history:
|
|
1073
|
+
sections.append("## Previous Review Conversation")
|
|
1074
|
+
for item in history:
|
|
1075
|
+
role = "User" if item["role"] == "user" else "Reviewer"
|
|
1076
|
+
sections.append(f"### {role}")
|
|
1077
|
+
sections.append(item["text"])
|
|
1078
|
+
sections.append("")
|
|
1079
|
+
sections.extend(["## New User Prompt", prompt])
|
|
1080
|
+
return "\n".join(sections).strip()
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def run_gemini_cli_review(
|
|
1084
|
+
prompt: str,
|
|
1085
|
+
*,
|
|
1086
|
+
history: list[dict[str, str]],
|
|
1087
|
+
model: str | None,
|
|
1088
|
+
system: str | None,
|
|
1089
|
+
image_paths: list[str],
|
|
1090
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1091
|
+
if image_paths:
|
|
1092
|
+
return None, "Gemini CLI backend in this bridge does not support imagePaths; use backend=api"
|
|
1093
|
+
|
|
1094
|
+
bin_path = find_gemini_bin()
|
|
1095
|
+
if not bin_path:
|
|
1096
|
+
return None, f"Gemini CLI not found: {GEMINI_BIN}"
|
|
1097
|
+
|
|
1098
|
+
effective_prompt = build_cli_prompt(prompt, history=history, system=system)
|
|
1099
|
+
nul_error = ensure_no_nul(effective_prompt, "prompt")
|
|
1100
|
+
if nul_error:
|
|
1101
|
+
return None, nul_error
|
|
1102
|
+
cmd = [bin_path, "-p", effective_prompt, "--output-format", "json"]
|
|
1103
|
+
selected_model, model_error = select_model_name(model, DEFAULT_MODEL)
|
|
1104
|
+
if model_error:
|
|
1105
|
+
return None, model_error
|
|
1106
|
+
if selected_model:
|
|
1107
|
+
model_family_error = require_gemini_model(selected_model, "Gemini CLI")
|
|
1108
|
+
if model_family_error:
|
|
1109
|
+
return None, model_family_error
|
|
1110
|
+
cmd.extend(["-m", selected_model])
|
|
1111
|
+
|
|
1112
|
+
debug_log(f"RUN gemini-cli model={selected_model or 'default'} output_format=json")
|
|
1113
|
+
try:
|
|
1114
|
+
result, process_error, duration_ms = run_process_tree(
|
|
1115
|
+
cmd,
|
|
1116
|
+
timeout_sec=DEFAULT_TIMEOUT_SEC,
|
|
1117
|
+
)
|
|
1118
|
+
except (OSError, ValueError) as exc:
|
|
1119
|
+
return None, f"failed to launch Gemini CLI: {exc}"
|
|
1120
|
+
if process_error:
|
|
1121
|
+
return None, f"Gemini review {process_error}"
|
|
1122
|
+
if result is None:
|
|
1123
|
+
return None, "Gemini review failed without process details"
|
|
1124
|
+
|
|
1125
|
+
payload, parse_error = parse_gemini_json(result.stdout)
|
|
1126
|
+
if parse_error:
|
|
1127
|
+
stderr = result.stderr.strip()
|
|
1128
|
+
message = parse_error if not stderr else f"{parse_error}. stderr: {stderr}"
|
|
1129
|
+
return None, message
|
|
1130
|
+
|
|
1131
|
+
if payload is None:
|
|
1132
|
+
return None, "Failed to parse Gemini CLI output"
|
|
1133
|
+
if result.returncode != 0:
|
|
1134
|
+
return None, f"Gemini review failed: {extract_cli_error_message(result.stdout, result.stderr)}"
|
|
1135
|
+
|
|
1136
|
+
response_text = str(payload.get("response", "")).strip()
|
|
1137
|
+
if not response_text:
|
|
1138
|
+
return None, "Gemini CLI JSON payload does not contain a non-empty response field"
|
|
1139
|
+
|
|
1140
|
+
reported_model = str(payload.get("model", "") or selected_model or "gemini-cli")
|
|
1141
|
+
model_family_error = require_gemini_model(reported_model, "Gemini CLI")
|
|
1142
|
+
if model_family_error:
|
|
1143
|
+
return None, model_family_error
|
|
1144
|
+
|
|
1145
|
+
return {
|
|
1146
|
+
"response": response_text,
|
|
1147
|
+
"model": reported_model,
|
|
1148
|
+
"duration_ms": duration_ms,
|
|
1149
|
+
"stop_reason": payload.get("stop_reason"),
|
|
1150
|
+
"backend": "cli",
|
|
1151
|
+
}, None
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def run_agy_cli_review(
|
|
1155
|
+
prompt: str,
|
|
1156
|
+
*,
|
|
1157
|
+
history: list[dict[str, str]],
|
|
1158
|
+
model: str | None,
|
|
1159
|
+
system: str | None,
|
|
1160
|
+
image_paths: list[str],
|
|
1161
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1162
|
+
if image_paths:
|
|
1163
|
+
return None, "Antigravity CLI backend in this bridge does not support imagePaths; use backend=api"
|
|
1164
|
+
|
|
1165
|
+
bin_path = find_agy_bin()
|
|
1166
|
+
if not bin_path:
|
|
1167
|
+
return None, f"Antigravity CLI not found: {AGY_BIN}"
|
|
1168
|
+
requested_model, model_error = select_model_name(model)
|
|
1169
|
+
if model_error:
|
|
1170
|
+
return None, model_error
|
|
1171
|
+
|
|
1172
|
+
invocation_nonce = uuid.uuid4().hex
|
|
1173
|
+
effective_prompt = build_cli_prompt(prompt, history=history, system=system)
|
|
1174
|
+
effective_prompt = (
|
|
1175
|
+
f"{effective_prompt}\n\n"
|
|
1176
|
+
f"## Invocation Binding\n"
|
|
1177
|
+
f"gemini-review nonce: {invocation_nonce}"
|
|
1178
|
+
)
|
|
1179
|
+
nul_error = ensure_no_nul(effective_prompt, "prompt") or ensure_no_nul(DEFAULT_AGY_PRINT_TIMEOUT, "GEMINI_REVIEW_AGY_PRINT_TIMEOUT")
|
|
1180
|
+
if nul_error:
|
|
1181
|
+
return None, nul_error
|
|
1182
|
+
agy_log_dir = STATE_DIR / "agy-logs"
|
|
1183
|
+
try:
|
|
1184
|
+
ensure_private_dir(agy_log_dir)
|
|
1185
|
+
except OSError as exc:
|
|
1186
|
+
return None, f"failed to create private Antigravity log directory: {exc}"
|
|
1187
|
+
|
|
1188
|
+
with tempfile.TemporaryDirectory(prefix="agy-", dir=str(agy_log_dir)) as temp_dir:
|
|
1189
|
+
temp_path = Path(temp_dir)
|
|
1190
|
+
try:
|
|
1191
|
+
os.chmod(temp_path, 0o700)
|
|
1192
|
+
except OSError:
|
|
1193
|
+
pass
|
|
1194
|
+
agy_log_path = temp_path / "agy.log"
|
|
1195
|
+
cmd = [
|
|
1196
|
+
bin_path,
|
|
1197
|
+
"--log-file",
|
|
1198
|
+
str(agy_log_path),
|
|
1199
|
+
"--print",
|
|
1200
|
+
effective_prompt,
|
|
1201
|
+
"--print-timeout",
|
|
1202
|
+
DEFAULT_AGY_PRINT_TIMEOUT,
|
|
1203
|
+
]
|
|
1204
|
+
|
|
1205
|
+
debug_log(f"RUN agy-cli timeout={DEFAULT_AGY_PRINT_TIMEOUT} log={agy_log_path}")
|
|
1206
|
+
try:
|
|
1207
|
+
invocation_started_at = time.time()
|
|
1208
|
+
result, process_error, duration_ms = run_process_tree(
|
|
1209
|
+
cmd,
|
|
1210
|
+
timeout_sec=DEFAULT_TIMEOUT_SEC + 15,
|
|
1211
|
+
)
|
|
1212
|
+
except (OSError, ValueError) as exc:
|
|
1213
|
+
return None, f"failed to launch Antigravity CLI: {exc}"
|
|
1214
|
+
if process_error:
|
|
1215
|
+
return None, f"Antigravity review {process_error}"
|
|
1216
|
+
if result is None:
|
|
1217
|
+
return None, "Antigravity review failed without process details"
|
|
1218
|
+
|
|
1219
|
+
response_text = result.stdout.strip()
|
|
1220
|
+
recovered_conversation_id: str | None = None
|
|
1221
|
+
if result.returncode != 0:
|
|
1222
|
+
message = result.stderr.strip() or response_text or "unknown error"
|
|
1223
|
+
return None, f"Antigravity review failed: {message}"
|
|
1224
|
+
if response_text and agy_output_is_error(response_text):
|
|
1225
|
+
recovered_text, recovered_error, recovered_conversation_id = extract_agy_response_from_state(
|
|
1226
|
+
agy_log_path,
|
|
1227
|
+
invocation_nonce=invocation_nonce,
|
|
1228
|
+
artifact_min_mtime=invocation_started_at,
|
|
1229
|
+
)
|
|
1230
|
+
if recovered_text:
|
|
1231
|
+
response_text = recovered_text
|
|
1232
|
+
elif response_text:
|
|
1233
|
+
suffix = f"; {recovered_error}" if recovered_error else ""
|
|
1234
|
+
return None, f"Antigravity CLI did not print a final response: {response_text}{suffix}"
|
|
1235
|
+
if not response_text:
|
|
1236
|
+
stderr = result.stderr.strip()
|
|
1237
|
+
recovered_text, recovered_error, recovered_conversation_id = extract_agy_response_from_state(
|
|
1238
|
+
agy_log_path,
|
|
1239
|
+
invocation_nonce=invocation_nonce,
|
|
1240
|
+
artifact_min_mtime=invocation_started_at,
|
|
1241
|
+
)
|
|
1242
|
+
if recovered_text:
|
|
1243
|
+
response_text = recovered_text
|
|
1244
|
+
else:
|
|
1245
|
+
message = "Antigravity CLI returned empty output" if not stderr else f"Antigravity CLI returned empty output. stderr: {stderr}"
|
|
1246
|
+
if recovered_error:
|
|
1247
|
+
message = f"{message}; {recovered_error}"
|
|
1248
|
+
return None, message
|
|
1249
|
+
|
|
1250
|
+
conversation_id = recovered_conversation_id
|
|
1251
|
+
if not conversation_id:
|
|
1252
|
+
conversation_id, conversation_error = select_agy_conversation_id_from_state(
|
|
1253
|
+
agy_log_path,
|
|
1254
|
+
invocation_nonce=invocation_nonce,
|
|
1255
|
+
)
|
|
1256
|
+
if conversation_error or not conversation_id:
|
|
1257
|
+
return None, conversation_error or "could not bind Antigravity transcript to this invocation"
|
|
1258
|
+
agy_model, agy_model_error = extract_agy_model_from_state(
|
|
1259
|
+
agy_log_path,
|
|
1260
|
+
conversation_id=conversation_id,
|
|
1261
|
+
invocation_nonce=invocation_nonce,
|
|
1262
|
+
)
|
|
1263
|
+
if agy_model_error:
|
|
1264
|
+
return None, agy_model_error
|
|
1265
|
+
model_family_error = require_gemini_model(agy_model, "Antigravity CLI")
|
|
1266
|
+
if model_family_error:
|
|
1267
|
+
return None, (
|
|
1268
|
+
f"{model_family_error}. The agy backend must expose the actual reviewer model "
|
|
1269
|
+
"in this invocation's log/transcript so ARIS can audit the cross-model invariant."
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
payload: dict[str, Any] = {
|
|
1273
|
+
"response": response_text,
|
|
1274
|
+
"model": agy_model,
|
|
1275
|
+
"model_provenance": "agy-log-or-transcript",
|
|
1276
|
+
"duration_ms": duration_ms,
|
|
1277
|
+
"stop_reason": None,
|
|
1278
|
+
"backend": "agy",
|
|
1279
|
+
}
|
|
1280
|
+
if requested_model:
|
|
1281
|
+
payload["requested_model"] = requested_model
|
|
1282
|
+
payload["warning"] = (
|
|
1283
|
+
"Antigravity CLI does not support per-call model selection in this bridge; "
|
|
1284
|
+
"requested_model is recorded for provenance only. "
|
|
1285
|
+
f"Actual model was recovered as {agy_model!r}."
|
|
1286
|
+
)
|
|
1287
|
+
return payload, None
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def run_gemini_api_review(
|
|
1291
|
+
prompt: str,
|
|
1292
|
+
*,
|
|
1293
|
+
history: list[dict[str, str]],
|
|
1294
|
+
model: str | None,
|
|
1295
|
+
system: str | None,
|
|
1296
|
+
image_paths: list[str],
|
|
1297
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1298
|
+
api_key = get_api_key()
|
|
1299
|
+
if not api_key:
|
|
1300
|
+
return None, "Gemini API backend requires GEMINI_API_KEY or GOOGLE_API_KEY"
|
|
1301
|
+
|
|
1302
|
+
selected_model, model_error = select_model_name(model, DEFAULT_MODEL, DEFAULT_API_MODEL)
|
|
1303
|
+
if model_error:
|
|
1304
|
+
return None, model_error
|
|
1305
|
+
model_family_error = require_gemini_model(selected_model, "Gemini API")
|
|
1306
|
+
if model_family_error:
|
|
1307
|
+
return None, model_family_error
|
|
1308
|
+
request_payload: dict[str, Any] = {
|
|
1309
|
+
"contents": [],
|
|
1310
|
+
"generationConfig": {"temperature": 0.2},
|
|
1311
|
+
}
|
|
1312
|
+
selected_system = (system or DEFAULT_SYSTEM).strip()
|
|
1313
|
+
if selected_system:
|
|
1314
|
+
request_payload["systemInstruction"] = {"parts": [{"text": selected_system}]}
|
|
1315
|
+
for item in history:
|
|
1316
|
+
request_payload["contents"].append(
|
|
1317
|
+
{
|
|
1318
|
+
"role": item["role"],
|
|
1319
|
+
"parts": [{"text": item["text"]}],
|
|
1320
|
+
}
|
|
1321
|
+
)
|
|
1322
|
+
user_parts: list[dict[str, Any]] = [{"text": prompt}]
|
|
1323
|
+
inline_parts, image_error = build_inline_image_parts(image_paths)
|
|
1324
|
+
if image_error:
|
|
1325
|
+
return None, image_error
|
|
1326
|
+
user_parts.extend(inline_parts)
|
|
1327
|
+
request_payload["contents"].append({"role": "user", "parts": user_parts})
|
|
1328
|
+
|
|
1329
|
+
model_path = gemini_api_model_path(selected_model)
|
|
1330
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/{model_path}:generateContent"
|
|
1331
|
+
request = urllib.request.Request(
|
|
1332
|
+
url,
|
|
1333
|
+
data=json.dumps(request_payload, ensure_ascii=False).encode("utf-8"),
|
|
1334
|
+
headers={
|
|
1335
|
+
"Content-Type": "application/json",
|
|
1336
|
+
"x-goog-api-key": api_key,
|
|
1337
|
+
},
|
|
1338
|
+
method="POST",
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
debug_log(f"RUN gemini-api {selected_model}")
|
|
1342
|
+
try:
|
|
1343
|
+
started = time.monotonic()
|
|
1344
|
+
with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT_SEC) as response:
|
|
1345
|
+
raw_stdout = response.read().decode("utf-8")
|
|
1346
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
1347
|
+
except urllib.error.HTTPError as exc:
|
|
1348
|
+
raw_text = exc.read().decode("utf-8", errors="replace")
|
|
1349
|
+
message = raw_text.strip()
|
|
1350
|
+
try:
|
|
1351
|
+
error_payload = json.loads(raw_text)
|
|
1352
|
+
if isinstance(error_payload, dict):
|
|
1353
|
+
error = error_payload.get("error")
|
|
1354
|
+
if isinstance(error, dict):
|
|
1355
|
+
api_message = error.get("message")
|
|
1356
|
+
if isinstance(api_message, str) and api_message.strip():
|
|
1357
|
+
message = api_message.strip()
|
|
1358
|
+
except json.JSONDecodeError:
|
|
1359
|
+
pass
|
|
1360
|
+
return None, f"Gemini API failed with HTTP {exc.code}: {message or 'unknown error'}"
|
|
1361
|
+
except urllib.error.URLError as exc:
|
|
1362
|
+
return None, f"Gemini API request failed: {exc.reason}"
|
|
1363
|
+
|
|
1364
|
+
try:
|
|
1365
|
+
api_payload = json.loads(raw_stdout)
|
|
1366
|
+
except json.JSONDecodeError:
|
|
1367
|
+
return None, "Gemini API response is not valid JSON"
|
|
1368
|
+
if not isinstance(api_payload, dict):
|
|
1369
|
+
return None, "Gemini API response JSON must be an object"
|
|
1370
|
+
|
|
1371
|
+
try:
|
|
1372
|
+
response_text = extract_api_response_text(api_payload)
|
|
1373
|
+
except ValueError as exc:
|
|
1374
|
+
return None, str(exc)
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
"response": response_text,
|
|
1378
|
+
"model": selected_model,
|
|
1379
|
+
"duration_ms": duration_ms,
|
|
1380
|
+
"stop_reason": None,
|
|
1381
|
+
"backend": "api",
|
|
1382
|
+
}, None
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def run_gemini_review(
|
|
1386
|
+
prompt: str,
|
|
1387
|
+
*,
|
|
1388
|
+
session_id: str | None = None,
|
|
1389
|
+
model: str | None = None,
|
|
1390
|
+
system: str | None = None,
|
|
1391
|
+
tools: str | None = None,
|
|
1392
|
+
backend: str | None = None,
|
|
1393
|
+
image_paths: Any = None,
|
|
1394
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1395
|
+
del tools
|
|
1396
|
+
|
|
1397
|
+
load_private_env_file()
|
|
1398
|
+
|
|
1399
|
+
normalized_image_paths, image_error = normalize_image_paths(image_paths)
|
|
1400
|
+
if image_error:
|
|
1401
|
+
return None, image_error
|
|
1402
|
+
|
|
1403
|
+
if session_id:
|
|
1404
|
+
thread_id, thread_error = normalize_safe_id(session_id, "threadId")
|
|
1405
|
+
if thread_error or thread_id is None:
|
|
1406
|
+
return None, thread_error or "invalid threadId"
|
|
1407
|
+
else:
|
|
1408
|
+
thread_id = uuid.uuid4().hex
|
|
1409
|
+
history = load_thread_history(thread_id) if session_id else []
|
|
1410
|
+
try:
|
|
1411
|
+
selected_backend = resolve_backend(backend)
|
|
1412
|
+
except ValueError as exc:
|
|
1413
|
+
return None, str(exc)
|
|
1414
|
+
|
|
1415
|
+
if selected_backend == "api":
|
|
1416
|
+
payload, error = run_gemini_api_review(
|
|
1417
|
+
prompt,
|
|
1418
|
+
history=history,
|
|
1419
|
+
model=model,
|
|
1420
|
+
system=system,
|
|
1421
|
+
image_paths=normalized_image_paths,
|
|
1422
|
+
)
|
|
1423
|
+
elif selected_backend == "agy":
|
|
1424
|
+
payload, error = run_agy_cli_review(
|
|
1425
|
+
prompt,
|
|
1426
|
+
history=history,
|
|
1427
|
+
model=model,
|
|
1428
|
+
system=system,
|
|
1429
|
+
image_paths=normalized_image_paths,
|
|
1430
|
+
)
|
|
1431
|
+
else:
|
|
1432
|
+
payload, error = run_gemini_cli_review(
|
|
1433
|
+
prompt,
|
|
1434
|
+
history=history,
|
|
1435
|
+
model=model,
|
|
1436
|
+
system=system,
|
|
1437
|
+
image_paths=normalized_image_paths,
|
|
1438
|
+
)
|
|
1439
|
+
if error:
|
|
1440
|
+
return None, error
|
|
1441
|
+
|
|
1442
|
+
if payload is None:
|
|
1443
|
+
return None, "Failed to parse reviewer output"
|
|
1444
|
+
updated_history = list(history)
|
|
1445
|
+
updated_history.append({"role": "user", "text": prompt})
|
|
1446
|
+
updated_history.append({"role": "model", "text": str(payload["response"])})
|
|
1447
|
+
save_thread_history(
|
|
1448
|
+
thread_id=thread_id,
|
|
1449
|
+
history=updated_history,
|
|
1450
|
+
model=str(payload["model"]),
|
|
1451
|
+
backend=str(payload["backend"]),
|
|
1452
|
+
)
|
|
1453
|
+
payload["threadId"] = thread_id
|
|
1454
|
+
return payload, None
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def start_async_review(
|
|
1458
|
+
prompt: str,
|
|
1459
|
+
*,
|
|
1460
|
+
session_id: str | None = None,
|
|
1461
|
+
model: str | None = None,
|
|
1462
|
+
system: str | None = None,
|
|
1463
|
+
tools: str | None = None,
|
|
1464
|
+
backend: str | None = None,
|
|
1465
|
+
image_paths: Any = None,
|
|
1466
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1467
|
+
normalized_image_paths, image_error = normalize_image_paths(image_paths)
|
|
1468
|
+
if image_error:
|
|
1469
|
+
return None, image_error
|
|
1470
|
+
if session_id:
|
|
1471
|
+
safe_session_id, thread_error = normalize_safe_id(session_id, "threadId")
|
|
1472
|
+
if thread_error or safe_session_id is None:
|
|
1473
|
+
return None, thread_error or "invalid threadId"
|
|
1474
|
+
session_id = safe_session_id
|
|
1475
|
+
_, model_error = normalize_model_name(model)
|
|
1476
|
+
if model_error:
|
|
1477
|
+
return None, model_error
|
|
1478
|
+
|
|
1479
|
+
job_id = uuid.uuid4().hex
|
|
1480
|
+
created_at = utc_now()
|
|
1481
|
+
job = {
|
|
1482
|
+
"jobId": job_id,
|
|
1483
|
+
"status": "queued",
|
|
1484
|
+
"createdAt": created_at,
|
|
1485
|
+
"startedAt": None,
|
|
1486
|
+
"completedAt": None,
|
|
1487
|
+
"updatedAt": created_at,
|
|
1488
|
+
"error": None,
|
|
1489
|
+
"result": None,
|
|
1490
|
+
"workerPid": None,
|
|
1491
|
+
"request": {
|
|
1492
|
+
"prompt": prompt,
|
|
1493
|
+
"threadId": session_id,
|
|
1494
|
+
"model": model,
|
|
1495
|
+
"system": system,
|
|
1496
|
+
"tools": tools,
|
|
1497
|
+
"backend": backend,
|
|
1498
|
+
"imagePaths": normalized_image_paths,
|
|
1499
|
+
},
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
job_path = job_state_path(job_id)
|
|
1503
|
+
write_json(job_path, job)
|
|
1504
|
+
|
|
1505
|
+
try:
|
|
1506
|
+
worker = subprocess.Popen(
|
|
1507
|
+
[sys.executable, str(Path(__file__).resolve()), "--run-job", job_id],
|
|
1508
|
+
stdin=subprocess.DEVNULL,
|
|
1509
|
+
stdout=subprocess.DEVNULL,
|
|
1510
|
+
stderr=subprocess.DEVNULL,
|
|
1511
|
+
close_fds=True,
|
|
1512
|
+
start_new_session=True,
|
|
1513
|
+
)
|
|
1514
|
+
except OSError as exc:
|
|
1515
|
+
job["status"] = "failed"
|
|
1516
|
+
job["completedAt"] = utc_now()
|
|
1517
|
+
job["updatedAt"] = job["completedAt"]
|
|
1518
|
+
job["error"] = f"Failed to launch background review worker: {exc}"
|
|
1519
|
+
write_json(job_path, job)
|
|
1520
|
+
return None, job["error"]
|
|
1521
|
+
|
|
1522
|
+
job["workerPid"] = worker.pid
|
|
1523
|
+
job["updatedAt"] = utc_now()
|
|
1524
|
+
write_json(job_path, job)
|
|
1525
|
+
debug_log(f"JOB_START job_id={job_id} worker_pid={worker.pid}")
|
|
1526
|
+
return serialize_job(job), None
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def get_review_status(job_id: str, *, wait_seconds: int = 0) -> tuple[dict[str, Any] | None, str | None]:
|
|
1530
|
+
safe_job_id, job_error = normalize_safe_id(job_id, "jobId")
|
|
1531
|
+
if job_error or safe_job_id is None:
|
|
1532
|
+
return None, job_error or "invalid jobId"
|
|
1533
|
+
if wait_seconds < 0 or wait_seconds > MAX_STATUS_WAIT_SECONDS:
|
|
1534
|
+
return None, f"waitSeconds must be between 0 and {MAX_STATUS_WAIT_SECONDS}"
|
|
1535
|
+
job_path = job_state_path(safe_job_id)
|
|
1536
|
+
if not job_path.exists():
|
|
1537
|
+
return None, f"Unknown jobId: {safe_job_id}"
|
|
1538
|
+
|
|
1539
|
+
deadline = time.monotonic() + wait_seconds
|
|
1540
|
+
while True:
|
|
1541
|
+
job = read_json(job_path)
|
|
1542
|
+
if job.get("status") in {"queued", "running"} and not is_pid_alive(job.get("workerPid")):
|
|
1543
|
+
job["status"] = "failed"
|
|
1544
|
+
job["error"] = "Background review worker exited before writing a final result"
|
|
1545
|
+
job["completedAt"] = utc_now()
|
|
1546
|
+
job["updatedAt"] = job["completedAt"]
|
|
1547
|
+
write_json(job_path, job)
|
|
1548
|
+
if job.get("status") in TERMINAL_JOB_STATES:
|
|
1549
|
+
return serialize_job(job), None
|
|
1550
|
+
if time.monotonic() >= deadline:
|
|
1551
|
+
return serialize_job(job), None
|
|
1552
|
+
time.sleep(min(0.5, max(deadline - time.monotonic(), 0.0)))
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def run_async_job(job_id: str) -> int:
|
|
1556
|
+
safe_job_id, job_error = normalize_safe_id(job_id, "jobId")
|
|
1557
|
+
if job_error or safe_job_id is None:
|
|
1558
|
+
debug_log(f"JOB_INVALID job_id={job_id!r} error={job_error}")
|
|
1559
|
+
return 1
|
|
1560
|
+
job_id = safe_job_id
|
|
1561
|
+
job_path = job_state_path(job_id)
|
|
1562
|
+
if not job_path.exists():
|
|
1563
|
+
debug_log(f"JOB_MISSING job_id={job_id}")
|
|
1564
|
+
return 1
|
|
1565
|
+
|
|
1566
|
+
job = read_json(job_path)
|
|
1567
|
+
job["status"] = "running"
|
|
1568
|
+
job["startedAt"] = utc_now()
|
|
1569
|
+
job["updatedAt"] = job["startedAt"]
|
|
1570
|
+
job["workerPid"] = os.getpid()
|
|
1571
|
+
write_json(job_path, job)
|
|
1572
|
+
debug_log(f"JOB_RUNNING job_id={job_id} worker_pid={os.getpid()}")
|
|
1573
|
+
|
|
1574
|
+
request = job.get("request") or {}
|
|
1575
|
+
try:
|
|
1576
|
+
payload, error = run_gemini_review(
|
|
1577
|
+
str(request.get("prompt", "")),
|
|
1578
|
+
session_id=request.get("threadId"),
|
|
1579
|
+
model=request.get("model"),
|
|
1580
|
+
system=request.get("system"),
|
|
1581
|
+
tools=request.get("tools"),
|
|
1582
|
+
backend=request.get("backend"),
|
|
1583
|
+
image_paths=request.get("imagePaths"),
|
|
1584
|
+
)
|
|
1585
|
+
except Exception as exc:
|
|
1586
|
+
payload = None
|
|
1587
|
+
error = f"Background review crashed: {exc}"
|
|
1588
|
+
debug_log(traceback.format_exc())
|
|
1589
|
+
|
|
1590
|
+
finished_at = utc_now()
|
|
1591
|
+
job = read_json(job_path)
|
|
1592
|
+
job["updatedAt"] = finished_at
|
|
1593
|
+
job["completedAt"] = finished_at
|
|
1594
|
+
if error:
|
|
1595
|
+
job["status"] = "failed"
|
|
1596
|
+
job["error"] = error
|
|
1597
|
+
job["result"] = None
|
|
1598
|
+
debug_log(f"JOB_FAILED job_id={job_id} error={error}")
|
|
1599
|
+
write_json(job_path, job)
|
|
1600
|
+
return 1
|
|
1601
|
+
|
|
1602
|
+
job["status"] = "completed"
|
|
1603
|
+
job["error"] = None
|
|
1604
|
+
job["result"] = payload
|
|
1605
|
+
debug_log(f"JOB_COMPLETED job_id={job_id} thread_id={(payload or {}).get('threadId')}")
|
|
1606
|
+
write_json(job_path, job)
|
|
1607
|
+
return 0
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def tool_success(request_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1611
|
+
return {
|
|
1612
|
+
"jsonrpc": "2.0",
|
|
1613
|
+
"id": request_id,
|
|
1614
|
+
"result": {
|
|
1615
|
+
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}],
|
|
1616
|
+
},
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def tool_error(request_id: Any, message: str) -> dict[str, Any]:
|
|
1621
|
+
return {
|
|
1622
|
+
"jsonrpc": "2.0",
|
|
1623
|
+
"id": request_id,
|
|
1624
|
+
"result": {
|
|
1625
|
+
"content": [{"type": "text", "text": json.dumps({"error": message}, ensure_ascii=False)}],
|
|
1626
|
+
"isError": True,
|
|
1627
|
+
},
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def handle_request(request: dict[str, Any]) -> dict[str, Any] | None:
|
|
1632
|
+
request_id = request.get("id")
|
|
1633
|
+
method = request.get("method", "")
|
|
1634
|
+
params = request.get("params", {})
|
|
1635
|
+
debug_log(
|
|
1636
|
+
f"REQUEST id={request_id!r} method={method} "
|
|
1637
|
+
f"params={json.dumps(redact_for_debug(params), ensure_ascii=False)}"
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
if request_id is None:
|
|
1641
|
+
if method in {"notifications/initialized", "initialized"}:
|
|
1642
|
+
return None
|
|
1643
|
+
return None
|
|
1644
|
+
|
|
1645
|
+
if method == "initialize":
|
|
1646
|
+
return {
|
|
1647
|
+
"jsonrpc": "2.0",
|
|
1648
|
+
"id": request_id,
|
|
1649
|
+
"result": {
|
|
1650
|
+
"protocolVersion": "2024-11-05",
|
|
1651
|
+
"capabilities": {"tools": {}},
|
|
1652
|
+
"serverInfo": {"name": SERVER_NAME, "version": "1.0.0"},
|
|
1653
|
+
},
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if method == "ping":
|
|
1657
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
|
1658
|
+
|
|
1659
|
+
if method == "resources/list":
|
|
1660
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {"resources": []}}
|
|
1661
|
+
|
|
1662
|
+
if method == "resources/templates/list":
|
|
1663
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {"resourceTemplates": []}}
|
|
1664
|
+
|
|
1665
|
+
if method in {"notifications/initialized", "initialized"}:
|
|
1666
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
|
1667
|
+
|
|
1668
|
+
if method == "tools/list":
|
|
1669
|
+
common_properties = {
|
|
1670
|
+
"prompt": {"type": "string", "description": "Reviewer prompt"},
|
|
1671
|
+
"system": {"type": "string", "description": "Optional system prompt"},
|
|
1672
|
+
"model": {"type": "string", "description": "Optional Gemini model override"},
|
|
1673
|
+
"backend": {"type": "string", "description": "Optional Gemini backend override: auto, api, cli, or agy"},
|
|
1674
|
+
"tools": {"type": "string", "description": "Accepted for compatibility but ignored by Gemini review"},
|
|
1675
|
+
"imagePaths": {
|
|
1676
|
+
"type": "array",
|
|
1677
|
+
"items": {"type": "string"},
|
|
1678
|
+
"description": "Optional local image paths for Gemini API multimodal review",
|
|
1679
|
+
},
|
|
1680
|
+
"image_paths": {
|
|
1681
|
+
"type": "array",
|
|
1682
|
+
"items": {"type": "string"},
|
|
1683
|
+
"description": "Alias of imagePaths",
|
|
1684
|
+
},
|
|
1685
|
+
}
|
|
1686
|
+
reply_properties = {
|
|
1687
|
+
"threadId": {"type": "string", "description": "Gemini thread id from a previous review call"},
|
|
1688
|
+
"thread_id": {"type": "string", "description": "Alias of threadId"},
|
|
1689
|
+
**common_properties,
|
|
1690
|
+
}
|
|
1691
|
+
return {
|
|
1692
|
+
"jsonrpc": "2.0",
|
|
1693
|
+
"id": request_id,
|
|
1694
|
+
"result": {
|
|
1695
|
+
"tools": [
|
|
1696
|
+
{
|
|
1697
|
+
"name": "review",
|
|
1698
|
+
"description": "Run a fresh Gemini review and return JSON containing threadId and response.",
|
|
1699
|
+
"inputSchema": {
|
|
1700
|
+
"type": "object",
|
|
1701
|
+
"properties": common_properties,
|
|
1702
|
+
"required": ["prompt"],
|
|
1703
|
+
},
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
"name": "review_reply",
|
|
1707
|
+
"description": "Continue a previous Gemini review session using threadId.",
|
|
1708
|
+
"inputSchema": {
|
|
1709
|
+
"type": "object",
|
|
1710
|
+
"properties": reply_properties,
|
|
1711
|
+
"required": ["prompt", "threadId"],
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
{
|
|
1715
|
+
"name": "review_start",
|
|
1716
|
+
"description": "Start a background Gemini review job and return a resumable jobId immediately.",
|
|
1717
|
+
"inputSchema": {
|
|
1718
|
+
"type": "object",
|
|
1719
|
+
"properties": common_properties,
|
|
1720
|
+
"required": ["prompt"],
|
|
1721
|
+
},
|
|
1722
|
+
},
|
|
1723
|
+
{
|
|
1724
|
+
"name": "review_reply_start",
|
|
1725
|
+
"description": "Start a background follow-up review job in an existing Gemini thread and return a resumable jobId immediately.",
|
|
1726
|
+
"inputSchema": {
|
|
1727
|
+
"type": "object",
|
|
1728
|
+
"properties": reply_properties,
|
|
1729
|
+
"required": ["prompt", "threadId"],
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1732
|
+
{
|
|
1733
|
+
"name": "review_status",
|
|
1734
|
+
"description": "Check whether a background review job has finished and fetch the final result when available.",
|
|
1735
|
+
"inputSchema": {
|
|
1736
|
+
"type": "object",
|
|
1737
|
+
"properties": {
|
|
1738
|
+
"jobId": {"type": "string", "description": "Background review job id"},
|
|
1739
|
+
"job_id": {"type": "string", "description": "Alias of jobId"},
|
|
1740
|
+
"waitSeconds": {
|
|
1741
|
+
"type": "integer",
|
|
1742
|
+
"minimum": 0,
|
|
1743
|
+
"maximum": MAX_STATUS_WAIT_SECONDS,
|
|
1744
|
+
"description": "Optional bounded wait before returning status",
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
"required": ["jobId"],
|
|
1748
|
+
},
|
|
1749
|
+
},
|
|
1750
|
+
]
|
|
1751
|
+
},
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if method == "tools/call":
|
|
1755
|
+
name = params.get("name", "")
|
|
1756
|
+
args = params.get("arguments", {}) or {}
|
|
1757
|
+
|
|
1758
|
+
if name == "review":
|
|
1759
|
+
payload, error = run_gemini_review(
|
|
1760
|
+
str(args.get("prompt", "")),
|
|
1761
|
+
model=args.get("model"),
|
|
1762
|
+
system=args.get("system"),
|
|
1763
|
+
tools=args.get("tools"),
|
|
1764
|
+
backend=args.get("backend"),
|
|
1765
|
+
image_paths=args.get("imagePaths") or args.get("image_paths"),
|
|
1766
|
+
)
|
|
1767
|
+
return tool_error(request_id, error) if error else tool_success(request_id, payload or {})
|
|
1768
|
+
|
|
1769
|
+
if name == "review_reply":
|
|
1770
|
+
thread_id = args.get("threadId") or args.get("thread_id")
|
|
1771
|
+
if not thread_id:
|
|
1772
|
+
return tool_error(request_id, "threadId or thread_id is required")
|
|
1773
|
+
payload, error = run_gemini_review(
|
|
1774
|
+
str(args.get("prompt", "")),
|
|
1775
|
+
session_id=str(thread_id),
|
|
1776
|
+
model=args.get("model"),
|
|
1777
|
+
system=args.get("system"),
|
|
1778
|
+
tools=args.get("tools"),
|
|
1779
|
+
backend=args.get("backend"),
|
|
1780
|
+
image_paths=args.get("imagePaths") or args.get("image_paths"),
|
|
1781
|
+
)
|
|
1782
|
+
return tool_error(request_id, error) if error else tool_success(request_id, payload or {})
|
|
1783
|
+
|
|
1784
|
+
if name == "review_start":
|
|
1785
|
+
payload, error = start_async_review(
|
|
1786
|
+
str(args.get("prompt", "")),
|
|
1787
|
+
model=args.get("model"),
|
|
1788
|
+
system=args.get("system"),
|
|
1789
|
+
tools=args.get("tools"),
|
|
1790
|
+
backend=args.get("backend"),
|
|
1791
|
+
image_paths=args.get("imagePaths") or args.get("image_paths"),
|
|
1792
|
+
)
|
|
1793
|
+
return tool_error(request_id, error) if error else tool_success(request_id, payload or {})
|
|
1794
|
+
|
|
1795
|
+
if name == "review_reply_start":
|
|
1796
|
+
thread_id = args.get("threadId") or args.get("thread_id")
|
|
1797
|
+
if not thread_id:
|
|
1798
|
+
return tool_error(request_id, "threadId or thread_id is required")
|
|
1799
|
+
payload, error = start_async_review(
|
|
1800
|
+
str(args.get("prompt", "")),
|
|
1801
|
+
session_id=str(thread_id),
|
|
1802
|
+
model=args.get("model"),
|
|
1803
|
+
system=args.get("system"),
|
|
1804
|
+
tools=args.get("tools"),
|
|
1805
|
+
backend=args.get("backend"),
|
|
1806
|
+
image_paths=args.get("imagePaths") or args.get("image_paths"),
|
|
1807
|
+
)
|
|
1808
|
+
return tool_error(request_id, error) if error else tool_success(request_id, payload or {})
|
|
1809
|
+
|
|
1810
|
+
if name == "review_status":
|
|
1811
|
+
job_id = args.get("jobId") or args.get("job_id")
|
|
1812
|
+
if not job_id:
|
|
1813
|
+
return tool_error(request_id, "jobId or job_id is required")
|
|
1814
|
+
wait_seconds_raw = args.get("waitSeconds", 0)
|
|
1815
|
+
try:
|
|
1816
|
+
wait_seconds = int(wait_seconds_raw)
|
|
1817
|
+
except (TypeError, ValueError):
|
|
1818
|
+
return tool_error(request_id, "waitSeconds must be an integer")
|
|
1819
|
+
payload, error = get_review_status(str(job_id), wait_seconds=wait_seconds)
|
|
1820
|
+
return tool_error(request_id, error) if error else tool_success(request_id, payload or {})
|
|
1821
|
+
|
|
1822
|
+
return {
|
|
1823
|
+
"jsonrpc": "2.0",
|
|
1824
|
+
"id": request_id,
|
|
1825
|
+
"error": {"code": -32601, "message": f"Unknown tool: {name}"},
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return {
|
|
1829
|
+
"jsonrpc": "2.0",
|
|
1830
|
+
"id": request_id,
|
|
1831
|
+
"error": {"code": -32601, "message": f"Unknown method: {method}"},
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def main() -> None:
|
|
1836
|
+
_init_stdio()
|
|
1837
|
+
if len(sys.argv) == 3 and sys.argv[1] == "--run-job":
|
|
1838
|
+
raise SystemExit(run_async_job(sys.argv[2]))
|
|
1839
|
+
|
|
1840
|
+
debug_log(f"=== {SERVER_NAME} starting ===")
|
|
1841
|
+
while True:
|
|
1842
|
+
try:
|
|
1843
|
+
request = read_message()
|
|
1844
|
+
if request is None:
|
|
1845
|
+
debug_log("EOF")
|
|
1846
|
+
break
|
|
1847
|
+
response = handle_request(request)
|
|
1848
|
+
if response is not None:
|
|
1849
|
+
send_response(response)
|
|
1850
|
+
except Exception:
|
|
1851
|
+
debug_log(traceback.format_exc())
|
|
1852
|
+
break
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
if __name__ == "__main__":
|
|
1856
|
+
main()
|