alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,759 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import mimetypes
7
+ import os
8
+ import re
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable
13
+ from contextlib import suppress
14
+ from dataclasses import replace
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..atomic_io import atomic_write_text
19
+ from ..config import (
20
+ AppConfig,
21
+ ConfigError,
22
+ get_api_key,
23
+ resolve_llm_enable_thinking,
24
+ resolve_llm_timeout_s,
25
+ resolve_model_access_api_key,
26
+ resolve_profile_api_key,
27
+ resolve_prompt_cache_key,
28
+ resolve_prompt_cache_retention,
29
+ resolve_role_temperature,
30
+ )
31
+ from ..forge import RunPaths, now_iso
32
+ from ..llm.factory import make_llm_client
33
+ from ..llm.types import LLMError, LLMResponse
34
+ from ..model_registry import ModelRegistry
35
+ from ..model_router import ROLE_COMPREHENSION, resolve_model_for_role
36
+ from ..profiles import ProfileSpec, get_profile
37
+ from .index import AssetIndex
38
+ from .models import (
39
+ AssetError,
40
+ AssetRecord,
41
+ ComprehensionData,
42
+ ComprehensionRecord,
43
+ )
44
+ from .ocr import OcrProvider, OcrResult
45
+ from .paths import asset_extracted_text_path, asset_preview_path, repo_rel
46
+ from .prompts import (
47
+ build_retry_prompt,
48
+ build_text_comprehension_prompt,
49
+ build_vision_comprehension_prompt,
50
+ )
51
+
52
+ _JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
53
+ _MAX_FAILURE_ERROR_CHARS = 500
54
+ _TOP_LEVEL_KEYS = {"detected_language", "language_confidence", "data"}
55
+ _DATA_KEYS = {
56
+ "semantic_summary",
57
+ "classification",
58
+ "key_entities",
59
+ "stated_facts",
60
+ "stated_decisions",
61
+ "stated_constraints",
62
+ "actionable_signals",
63
+ "open_questions",
64
+ "relations_hint",
65
+ "confidence",
66
+ }
67
+ _CLASSIFICATION_KEYS = {"kind", "subkind", "domain"}
68
+ _COMPREHENSION_TOOL_NAME = "record_asset_comprehension"
69
+ _COMPREHENSION_TOOL_CHOICE = {
70
+ "type": "function",
71
+ "function": {"name": _COMPREHENSION_TOOL_NAME},
72
+ }
73
+ _COMPREHENSION_TOOL: dict[str, Any] = {
74
+ "type": "function",
75
+ "function": {
76
+ "name": _COMPREHENSION_TOOL_NAME,
77
+ "description": "Persist the structured comprehension for a user-provided asset.",
78
+ "strict": True,
79
+ "parameters": {
80
+ "type": "object",
81
+ "additionalProperties": False,
82
+ "required": ["detected_language", "language_confidence", "data"],
83
+ "properties": {
84
+ "detected_language": {
85
+ "anyOf": [{"type": "string"}, {"type": "null"}],
86
+ "description": "ISO 639-1 primary language code, or null if unknown.",
87
+ },
88
+ "language_confidence": {
89
+ "anyOf": [{"type": "number"}, {"type": "null"}],
90
+ "description": "Confidence from 0.0 to 1.0, or null if unknown.",
91
+ },
92
+ "data": {
93
+ "type": "object",
94
+ "additionalProperties": False,
95
+ "required": [
96
+ "semantic_summary",
97
+ "classification",
98
+ "key_entities",
99
+ "stated_facts",
100
+ "stated_decisions",
101
+ "stated_constraints",
102
+ "actionable_signals",
103
+ "open_questions",
104
+ "relations_hint",
105
+ "confidence",
106
+ ],
107
+ "properties": {
108
+ "semantic_summary": {"type": "string"},
109
+ "classification": {
110
+ "type": "object",
111
+ "additionalProperties": False,
112
+ "required": ["kind", "subkind", "domain"],
113
+ "properties": {
114
+ "kind": {"type": "string"},
115
+ "subkind": {"type": "string"},
116
+ "domain": {"type": "string"},
117
+ },
118
+ },
119
+ "key_entities": {
120
+ "type": "array",
121
+ "items": {
122
+ "type": "object",
123
+ "additionalProperties": False,
124
+ "required": ["type", "value"],
125
+ "properties": {
126
+ "type": {"type": "string"},
127
+ "value": {"type": "string"},
128
+ },
129
+ },
130
+ },
131
+ "stated_facts": {"type": "array", "items": {"type": "string"}},
132
+ "stated_decisions": {"type": "array", "items": {"type": "string"}},
133
+ "stated_constraints": {"type": "array", "items": {"type": "string"}},
134
+ "actionable_signals": {"type": "array", "items": {"type": "string"}},
135
+ "open_questions": {"type": "array", "items": {"type": "string"}},
136
+ "relations_hint": {"type": "string"},
137
+ "confidence": {
138
+ "type": "object",
139
+ "additionalProperties": {"type": "number"},
140
+ },
141
+ },
142
+ },
143
+ },
144
+ },
145
+ },
146
+ }
147
+ LOGGER = logging.getLogger(__name__)
148
+
149
+
150
+ class AssetComprehender:
151
+ def __init__(
152
+ self,
153
+ *,
154
+ cfg: AppConfig,
155
+ model_registry: ModelRegistry,
156
+ ocr_provider: OcrProvider | None,
157
+ run_paths: RunPaths,
158
+ ) -> None:
159
+ self.cfg = cfg
160
+ self.model_registry = model_registry
161
+ self.ocr_provider = ocr_provider
162
+ self.run_paths = run_paths
163
+ self.index = AssetIndex(run_paths)
164
+
165
+ def comprehend(
166
+ self,
167
+ asset: AssetRecord,
168
+ *,
169
+ previous: ComprehensionRecord | None = None,
170
+ angle: str | None = None,
171
+ ) -> ComprehensionRecord:
172
+ _ = previous
173
+ started = time.monotonic()
174
+ try:
175
+ if asset.kind == "text":
176
+ return self._comprehend_text_asset(asset=asset, started=started, angle=angle)
177
+ if asset.kind == "image":
178
+ return self._comprehend_image_asset(asset=asset, started=started, angle=angle)
179
+ raise AssetError(f"Unsupported asset kind: {asset.kind}")
180
+ except Exception as exc: # noqa: BLE001 - failures must become durable records
181
+ failed = self._failed_record(asset=asset, started=started, error=str(exc))
182
+ return self.index.write_comprehension(failed)
183
+
184
+ def comprehend_async_threadable(
185
+ self,
186
+ asset: AssetRecord,
187
+ callback: Callable[[ComprehensionRecord], None],
188
+ ) -> threading.Thread:
189
+ def _target() -> None:
190
+ callback(self.comprehend(asset))
191
+
192
+ thread = threading.Thread(target=_target, daemon=True)
193
+ thread.start()
194
+ return thread
195
+
196
+ def _comprehend_text_asset(
197
+ self,
198
+ *,
199
+ asset: AssetRecord,
200
+ started: float,
201
+ angle: str | None,
202
+ ) -> ComprehensionRecord:
203
+ role, model, profile = self._primary_model()
204
+ if not _has_inspection_angle(angle):
205
+ cached = self._cache_hit(asset=asset, model=model, role=role, started=started)
206
+ if cached is not None:
207
+ return cached
208
+ text = self._load_extracted_text(asset)
209
+ prompt = build_text_comprehension_prompt(
210
+ asset=asset,
211
+ text=text,
212
+ questioning_mode=self.cfg.assets.comprehension.questioning_mode,
213
+ angle=angle,
214
+ )
215
+ record = self._llm_comprehend(
216
+ asset=asset,
217
+ prompt=prompt,
218
+ source="text_only",
219
+ model=model,
220
+ role=role,
221
+ profile=profile,
222
+ started=started,
223
+ confidence_modifier=1.0,
224
+ )
225
+ return self.index.write_comprehension(record)
226
+
227
+ def _comprehend_image_asset(
228
+ self,
229
+ *,
230
+ asset: AssetRecord,
231
+ started: float,
232
+ angle: str | None,
233
+ ) -> ComprehensionRecord:
234
+ role, primary_model, primary_profile = self._primary_model()
235
+ if self.model_registry.get(primary_model).supports_vision:
236
+ if not _has_inspection_angle(angle):
237
+ cached = self._cache_hit(
238
+ asset=asset,
239
+ model=primary_model,
240
+ role=role,
241
+ started=started,
242
+ )
243
+ if cached is not None:
244
+ return cached
245
+ return self._vision_comprehend(
246
+ asset=asset,
247
+ model=primary_model,
248
+ role=role,
249
+ profile=primary_profile,
250
+ started=started,
251
+ angle=angle,
252
+ )
253
+
254
+ fallback = self._vision_fallback_model()
255
+ if fallback is not None:
256
+ fallback_role, fallback_model, fallback_profile = fallback
257
+ if not _has_inspection_angle(angle):
258
+ cached = self._cache_hit(
259
+ asset=asset,
260
+ model=fallback_model,
261
+ role=fallback_role,
262
+ started=started,
263
+ )
264
+ if cached is not None:
265
+ return cached
266
+ return self._vision_comprehend(
267
+ asset=asset,
268
+ model=fallback_model,
269
+ role=fallback_role,
270
+ profile=fallback_profile,
271
+ started=started,
272
+ angle=angle,
273
+ )
274
+
275
+ ocr_result = self._try_ocr(asset)
276
+ if ocr_result is not None and ocr_result.text.strip():
277
+ extracted_path = asset_extracted_text_path(self.run_paths, asset.id)
278
+ atomic_write_text(extracted_path, ocr_result.text)
279
+ updated_asset = self.index.update(
280
+ replace(asset, extracted_text_path=repo_rel(self.run_paths.root, extracted_path))
281
+ )
282
+ if not _has_inspection_angle(angle):
283
+ cached = self._cache_hit(
284
+ asset=updated_asset,
285
+ model=primary_model,
286
+ role=role,
287
+ started=started,
288
+ )
289
+ if cached is not None:
290
+ return cached
291
+ prompt = build_text_comprehension_prompt(
292
+ asset=updated_asset,
293
+ text=ocr_result.text,
294
+ questioning_mode=self.cfg.assets.comprehension.questioning_mode,
295
+ angle=angle,
296
+ )
297
+ record = self._llm_comprehend(
298
+ asset=updated_asset,
299
+ prompt=prompt,
300
+ source="ocr_only",
301
+ model=primary_model,
302
+ role=role,
303
+ profile=primary_profile,
304
+ started=started,
305
+ confidence_modifier=0.7,
306
+ ocr_result=ocr_result,
307
+ )
308
+ return self.index.write_comprehension(record)
309
+
310
+ minimal = self._minimal_record(asset=asset, started=started)
311
+ return self.index.write_comprehension(minimal)
312
+
313
+ def _vision_comprehend(
314
+ self,
315
+ *,
316
+ asset: AssetRecord,
317
+ model: str,
318
+ role: str,
319
+ profile: ProfileSpec | None,
320
+ started: float,
321
+ angle: str | None,
322
+ ) -> ComprehensionRecord:
323
+ ocr_result: OcrResult | None = None
324
+ combine_with_ocr = (
325
+ self.cfg.assets.comprehension.vision_with_ocr_when_available
326
+ and self._ocr_allowed()
327
+ and self.ocr_provider is not None
328
+ and self.ocr_provider.is_available()
329
+ )
330
+ if combine_with_ocr:
331
+ ocr_result = self._try_ocr(asset)
332
+ if ocr_result is not None and ocr_result.text.strip():
333
+ extracted_path = asset_extracted_text_path(self.run_paths, asset.id)
334
+ atomic_write_text(extracted_path, ocr_result.text)
335
+ asset = self.index.update(
336
+ replace(
337
+ asset, extracted_text_path=repo_rel(self.run_paths.root, extracted_path)
338
+ )
339
+ )
340
+ preview_path = self._normalized_preview(asset)
341
+ prompt = build_vision_comprehension_prompt(
342
+ asset=asset,
343
+ questioning_mode=self.cfg.assets.comprehension.questioning_mode,
344
+ ocr_text=ocr_result.text if ocr_result is not None else None,
345
+ angle=angle,
346
+ )
347
+ source = (
348
+ "vision_with_ocr" if ocr_result is not None and ocr_result.text.strip() else "vision"
349
+ )
350
+ record = self._llm_comprehend(
351
+ asset=asset,
352
+ prompt=prompt,
353
+ source=source,
354
+ model=model,
355
+ role=role,
356
+ profile=profile,
357
+ started=started,
358
+ confidence_modifier=1.0,
359
+ ocr_result=ocr_result,
360
+ image_path=preview_path,
361
+ )
362
+ return self.index.write_comprehension(record)
363
+
364
+ def _llm_comprehend(
365
+ self,
366
+ *,
367
+ asset: AssetRecord,
368
+ prompt: str,
369
+ source: str,
370
+ model: str,
371
+ role: str,
372
+ profile: ProfileSpec | None,
373
+ started: float,
374
+ confidence_modifier: float,
375
+ ocr_result: OcrResult | None = None,
376
+ image_path: Path | None = None,
377
+ ) -> ComprehensionRecord:
378
+ response = self._chat(
379
+ model=model, role=role, profile=profile, prompt=prompt, image_path=image_path
380
+ )
381
+ try:
382
+ payload = _parse_comprehension_payload(response)
383
+ except AssetError:
384
+ retry = self._chat(
385
+ model=model,
386
+ role=role,
387
+ profile=profile,
388
+ prompt=build_retry_prompt(prompt),
389
+ image_path=image_path,
390
+ )
391
+ payload = _parse_comprehension_payload(retry)
392
+ response = retry
393
+ data = ComprehensionData.from_dict(payload.get("data"))
394
+ usage = _tokens_used(response)
395
+ return ComprehensionRecord(
396
+ schema_version=1,
397
+ version=0,
398
+ asset_id=asset.id,
399
+ status="ready",
400
+ source=source, # type: ignore[arg-type]
401
+ model=model,
402
+ role=role,
403
+ ocr_engine=ocr_result.engine_version if ocr_result is not None else None,
404
+ ocr_languages_used=ocr_result.languages_used if ocr_result is not None else [],
405
+ detected_language=_optional_string(payload.get("detected_language")),
406
+ language_confidence=_optional_float(payload.get("language_confidence")),
407
+ confidence_modifier=confidence_modifier,
408
+ tokens_used=usage,
409
+ elapsed_ms=_elapsed_ms(started),
410
+ generated_at=now_iso(),
411
+ error=None,
412
+ data=data,
413
+ )
414
+
415
+ def _chat(
416
+ self,
417
+ *,
418
+ model: str,
419
+ role: str,
420
+ profile: ProfileSpec | None,
421
+ prompt: str,
422
+ image_path: Path | None,
423
+ ) -> LLMResponse:
424
+ api_key = self._api_key_for_profile(profile)
425
+ client = make_llm_client(
426
+ cfg=self.cfg,
427
+ api_key=api_key,
428
+ model=model,
429
+ timeout_s=resolve_llm_timeout_s(self.cfg),
430
+ temperature=resolve_role_temperature(self.cfg, role=role),
431
+ prompt_cache_key=resolve_prompt_cache_key(self.cfg),
432
+ prompt_cache_retention=resolve_prompt_cache_retention(self.cfg),
433
+ enable_thinking=resolve_llm_enable_thinking(self.cfg),
434
+ profile=profile,
435
+ )
436
+ messages = [{"role": "system", "content": "Return structured JSON only."}]
437
+ if image_path is None:
438
+ messages.append({"role": "user", "content": prompt})
439
+ else:
440
+ messages.append({"role": "user", "content": _image_content_parts(prompt, image_path)})
441
+ try:
442
+ return client.chat(
443
+ messages=messages,
444
+ temperature=resolve_role_temperature(self.cfg, role=role),
445
+ tools=[_COMPREHENSION_TOOL],
446
+ tool_choice=_COMPREHENSION_TOOL_CHOICE,
447
+ )
448
+ except LLMError as exc:
449
+ if not _structured_tool_call_unsupported(exc):
450
+ raise
451
+ LOGGER.warning(
452
+ "Structured tool-call comprehension was rejected by the provider; retrying with JSON mode."
453
+ )
454
+ return client.chat(
455
+ messages=messages,
456
+ temperature=resolve_role_temperature(self.cfg, role=role),
457
+ response_format={"type": "json_object"},
458
+ )
459
+
460
+ def _api_key_for_profile(self, profile: ProfileSpec | None) -> str:
461
+ if profile is None:
462
+ return resolve_model_access_api_key(self.cfg, legacy_resolver=get_api_key)
463
+ if profile.auth_provider:
464
+ return ""
465
+ resolved = resolve_profile_api_key(self.cfg, profile.name)
466
+ if not resolved.key:
467
+ raise ConfigError(f"Missing API key for profile {profile.name}.")
468
+ return resolved.key
469
+
470
+ def _primary_model(self) -> tuple[str, str, ProfileSpec | None]:
471
+ role = str(self.cfg.assets.comprehension.role or ROLE_COMPREHENSION).strip().lower()
472
+ model = resolve_model_for_role(cfg=self.cfg, role=role, plan=None)
473
+ return role, model, None
474
+
475
+ def _vision_fallback_model(self) -> tuple[str, str, ProfileSpec] | None:
476
+ profile_name = str(self.cfg.assets.comprehension.vision_fallback_profile or "").strip()
477
+ if not profile_name:
478
+ return None
479
+ profile = get_profile(self.cfg, profile_name)
480
+ if profile is None or not profile.default_model:
481
+ return None
482
+ if not self.model_registry.get(profile.default_model).supports_vision:
483
+ return None
484
+ return ROLE_COMPREHENSION, profile.default_model, profile
485
+
486
+ def _cache_hit(
487
+ self,
488
+ *,
489
+ asset: AssetRecord,
490
+ model: str | None,
491
+ role: str | None,
492
+ started: float,
493
+ ) -> ComprehensionRecord | None:
494
+ cached = self.index.find_existing_comprehension_by_sha256(
495
+ asset.sha256,
496
+ model=model,
497
+ role=role,
498
+ )
499
+ if cached is None:
500
+ return None
501
+ cloned = replace(
502
+ cached,
503
+ version=0,
504
+ asset_id=asset.id,
505
+ elapsed_ms=_elapsed_ms(started),
506
+ generated_at=now_iso(),
507
+ )
508
+ return self.index.write_comprehension(cloned)
509
+
510
+ def _try_ocr(self, asset: AssetRecord) -> OcrResult | None:
511
+ if (
512
+ not self._ocr_allowed()
513
+ or self.ocr_provider is None
514
+ or not self.ocr_provider.is_available()
515
+ ):
516
+ return None
517
+ try:
518
+ return self.ocr_provider.extract_text(self.run_paths.root / asset.stored_path)
519
+ except Exception as exc:
520
+ if self.cfg.assets.comprehension.ocr_enabled == "always":
521
+ raise
522
+ LOGGER.warning("Asset OCR failed for %s: %s", asset.id, exc)
523
+ return None
524
+
525
+ def _ocr_allowed(self) -> bool:
526
+ return self.cfg.assets.comprehension.ocr_enabled != "never"
527
+
528
+ def _load_extracted_text(self, asset: AssetRecord) -> str:
529
+ path = asset.extracted_text_path or asset.stored_path
530
+ return (self.run_paths.root / path).read_text(encoding="utf-8")
531
+
532
+ def _normalized_preview(self, asset: AssetRecord) -> Path:
533
+ try:
534
+ from PIL import Image
535
+ except ModuleNotFoundError as e:
536
+ raise AssetError("Pillow is required for image comprehension.") from e
537
+ source = self.run_paths.root / asset.stored_path
538
+ destination = asset_preview_path(self.run_paths, asset.id)
539
+ max_edge = int(self.cfg.assets.comprehension.image_max_edge_pixels)
540
+ with Image.open(source) as image:
541
+ normalized = image.convert("RGB")
542
+ normalized.thumbnail((max_edge, max_edge))
543
+ destination.parent.mkdir(parents=True, exist_ok=True)
544
+ fd, temp_name = tempfile.mkstemp(
545
+ dir=destination.parent,
546
+ prefix=f".{destination.name}.",
547
+ suffix=".tmp",
548
+ )
549
+ temp_path = Path(temp_name)
550
+ os.close(fd)
551
+ try:
552
+ normalized.save(temp_path, format="PNG")
553
+ os.replace(temp_path, destination)
554
+ finally:
555
+ with suppress(FileNotFoundError):
556
+ temp_path.unlink()
557
+ return destination
558
+
559
+ def _minimal_record(self, *, asset: AssetRecord, started: float) -> ComprehensionRecord:
560
+ has_description = bool(asset.description.strip())
561
+ source = "user_description" if has_description else "minimal"
562
+ confidence_modifier = 0.5 if has_description else 0.3
563
+ summary_parts = [asset.title]
564
+ if has_description:
565
+ summary_parts.append(asset.description)
566
+ return ComprehensionRecord(
567
+ schema_version=1,
568
+ version=0,
569
+ asset_id=asset.id,
570
+ status="minimal",
571
+ source=source,
572
+ model=None,
573
+ role=None,
574
+ ocr_engine=None,
575
+ ocr_languages_used=[],
576
+ detected_language=None,
577
+ language_confidence=None,
578
+ confidence_modifier=confidence_modifier,
579
+ tokens_used={},
580
+ elapsed_ms=_elapsed_ms(started),
581
+ generated_at=now_iso(),
582
+ error=None,
583
+ data=ComprehensionData(
584
+ semantic_summary="\n".join(part for part in summary_parts if part.strip()),
585
+ classification={"kind": asset.kind, "subkind": "", "domain": ""},
586
+ confidence={"summary": confidence_modifier, "classification": confidence_modifier},
587
+ ),
588
+ )
589
+
590
+ def _failed_record(
591
+ self,
592
+ *,
593
+ asset: AssetRecord,
594
+ started: float,
595
+ error: str,
596
+ ) -> ComprehensionRecord:
597
+ return ComprehensionRecord(
598
+ schema_version=1,
599
+ version=0,
600
+ asset_id=asset.id,
601
+ status="failed",
602
+ source="minimal",
603
+ model=None,
604
+ role=None,
605
+ ocr_engine=None,
606
+ ocr_languages_used=[],
607
+ detected_language=None,
608
+ language_confidence=None,
609
+ confidence_modifier=0.0,
610
+ tokens_used={},
611
+ elapsed_ms=_elapsed_ms(started),
612
+ generated_at=now_iso(),
613
+ error=error.strip()[:_MAX_FAILURE_ERROR_CHARS] or "unknown error",
614
+ data=ComprehensionData(),
615
+ )
616
+
617
+
618
+ def _parse_comprehension_payload(response: LLMResponse) -> dict[str, Any]:
619
+ if response.tool_calls:
620
+ payload = None
621
+ for tool_call in response.tool_calls:
622
+ if tool_call.name == _COMPREHENSION_TOOL_NAME:
623
+ payload = tool_call.arguments
624
+ break
625
+ if payload is None:
626
+ raise AssetError(f"LLM did not call required tool: {_COMPREHENSION_TOOL_NAME}.")
627
+ else:
628
+ match = _JSON_OBJECT_RE.search(response.content.strip())
629
+ if not match:
630
+ raise AssetError("LLM did not return a JSON object.")
631
+ try:
632
+ payload = json.loads(match.group(0))
633
+ except json.JSONDecodeError as e:
634
+ raise AssetError("LLM returned malformed JSON.") from e
635
+ if not isinstance(payload, dict):
636
+ raise AssetError("LLM comprehension payload must be a JSON object.")
637
+ _require_exact_keys(payload, _TOP_LEVEL_KEYS, "LLM comprehension payload")
638
+ if not isinstance(payload.get("data"), dict):
639
+ raise AssetError("LLM comprehension payload must contain data object.")
640
+ _validate_comprehension_data(payload["data"])
641
+ detected_language = payload.get("detected_language")
642
+ if detected_language is not None and not isinstance(detected_language, str):
643
+ raise AssetError("LLM comprehension detected_language must be a string or null.")
644
+ language_confidence = payload.get("language_confidence")
645
+ if language_confidence is not None and not isinstance(language_confidence, int | float):
646
+ raise AssetError("LLM comprehension language_confidence must be numeric or null.")
647
+ return payload
648
+
649
+
650
+ def _has_inspection_angle(angle: str | None) -> bool:
651
+ return bool(str(angle or "").strip())
652
+
653
+
654
+ def _structured_tool_call_unsupported(error: LLMError) -> bool:
655
+ text = str(error).casefold()
656
+ if "tool" not in text and "function" not in text:
657
+ return False
658
+ return any(token in text for token in ("unsupported", "unknown", "invalid", "not allowed"))
659
+
660
+
661
+ def _validate_comprehension_data(data: dict[str, Any]) -> None:
662
+ _require_exact_keys(data, _DATA_KEYS, "LLM comprehension data")
663
+ if not isinstance(data["semantic_summary"], str):
664
+ raise AssetError("LLM comprehension data.semantic_summary must be a string.")
665
+ if not isinstance(data["relations_hint"], str):
666
+ raise AssetError("LLM comprehension data.relations_hint must be a string.")
667
+ classification = data["classification"]
668
+ if not isinstance(classification, dict):
669
+ raise AssetError("LLM comprehension data.classification must be an object.")
670
+ _require_exact_keys(classification, _CLASSIFICATION_KEYS, "LLM comprehension classification")
671
+ if not all(isinstance(value, str) for value in classification.values()):
672
+ raise AssetError("LLM comprehension classification values must be strings.")
673
+ _validate_entity_list(data["key_entities"])
674
+ for field_name in (
675
+ "stated_facts",
676
+ "stated_decisions",
677
+ "stated_constraints",
678
+ "actionable_signals",
679
+ "open_questions",
680
+ ):
681
+ _validate_string_list(data[field_name], f"LLM comprehension data.{field_name}")
682
+ confidence = data["confidence"]
683
+ if not isinstance(confidence, dict):
684
+ raise AssetError("LLM comprehension data.confidence must be an object.")
685
+ for key, value in confidence.items():
686
+ if not isinstance(key, str) or not isinstance(value, int | float):
687
+ raise AssetError("LLM comprehension confidence must map string keys to numeric values.")
688
+
689
+
690
+ def _require_exact_keys(payload: dict[str, Any], expected: set[str], label: str) -> None:
691
+ keys = set(payload)
692
+ missing = sorted(expected - keys)
693
+ unknown = sorted(keys - expected)
694
+ if missing:
695
+ raise AssetError(f"{label} is missing keys: {missing}")
696
+ if unknown:
697
+ raise AssetError(f"{label} has unexpected keys: {unknown}")
698
+
699
+
700
+ def _validate_entity_list(value: Any) -> None:
701
+ if not isinstance(value, list):
702
+ raise AssetError("LLM comprehension data.key_entities must be an array.")
703
+ for item in value:
704
+ if not isinstance(item, dict):
705
+ raise AssetError("LLM comprehension key_entities items must be objects.")
706
+ if set(item) != {"type", "value"}:
707
+ raise AssetError("LLM comprehension key_entities items need type and value keys.")
708
+ if not all(isinstance(raw_value, str) for raw_value in item.values()):
709
+ raise AssetError("LLM comprehension key_entities values must be strings.")
710
+
711
+
712
+ def _validate_string_list(value: Any, label: str) -> None:
713
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
714
+ raise AssetError(f"{label} must be an array of strings.")
715
+
716
+
717
+ def _tokens_used(response: LLMResponse) -> dict[str, int]:
718
+ usage = response.usage
719
+ if usage is None:
720
+ return {}
721
+ out: dict[str, int] = {}
722
+ if usage.prompt_tokens is not None:
723
+ out["input"] = int(usage.prompt_tokens)
724
+ if usage.completion_tokens is not None:
725
+ out["output"] = int(usage.completion_tokens)
726
+ return out
727
+
728
+
729
+ def _optional_string(value: Any) -> str | None:
730
+ if value is None:
731
+ return None
732
+ text = str(value).strip()
733
+ return text or None
734
+
735
+
736
+ def _optional_float(value: Any) -> float | None:
737
+ if value is None:
738
+ return None
739
+ try:
740
+ return float(value)
741
+ except (TypeError, ValueError):
742
+ return None
743
+
744
+
745
+ def _elapsed_ms(started: float) -> int:
746
+ return int((time.monotonic() - started) * 1000)
747
+
748
+
749
+ def _image_content_parts(prompt: str, image_path: Path) -> list[dict[str, Any]]:
750
+ mime, _ = mimetypes.guess_type(image_path.name)
751
+ image_mime = mime if mime and mime.startswith("image/") else "image/png"
752
+ encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
753
+ return [
754
+ {
755
+ "type": "image_url",
756
+ "image_url": {"url": f"data:{image_mime};base64,{encoded}"},
757
+ },
758
+ {"type": "text", "text": prompt},
759
+ ]