novacode-cli 1.2.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 (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,604 @@
1
+ """On-demand image inspection for a text-only main model.
2
+
3
+ When the main model **cannot** see images, raw image content stays in graph state
4
+ and is stripped from its model requests. The main model receives a note that it
5
+ can invoke ``VISION`` when it needs visual details. That tool makes one bounded
6
+ side-call to the separately configured vision model and returns a text caption.
7
+
8
+ When the main model **can** see images (a multimodal model — see
9
+ :mod:`novacode_cli.config.model_capabilities`), captioning is skipped entirely:
10
+ the image is passed straight through to the main model, which reads it directly.
11
+ This is the fix for the long-standing "read_file on an image returns
12
+ ``[image: vision model unavailable]`` even though my model is multimodal" bug —
13
+ the capable model was being bypassed in favour of an auxiliary one.
14
+
15
+ Two image sources are available to ``VISION`` (text-only main model only):
16
+
17
+ - **disk reads** — image-bearing ``read_file`` results remain in graph state.
18
+ - **clipboard pastes** — image blocks remain in the user message.
19
+
20
+ :meth:`VisionCaptionMiddleware.awrap_model_call` strips image blocks from
21
+ text-only requests and appends a tool-use hint. Multimodal main models receive
22
+ the image blocks directly. The session model never changes.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import base64
29
+ import functools
30
+ import io
31
+ import logging
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Awaitable, Callable
36
+
37
+ from langchain.tools.tool_node import ToolCallRequest
38
+ from langchain_core.language_models import BaseChatModel
39
+ from langgraph.types import Command
40
+
41
+ from langchain.agents.middleware.types import (
42
+ AgentMiddleware,
43
+ ModelRequest,
44
+ ModelResponse,
45
+ )
46
+ from langchain.tools import ToolRuntime, tool
47
+ from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
48
+
49
+ logger = logging.getLogger("nova.vision_router")
50
+
51
+ # MIME type fallback by extension suffix
52
+ _MIME_BY_SUFFIX: dict[str, str] = {
53
+ ".png": "image/png",
54
+ ".jpg": "image/jpeg",
55
+ ".jpeg": "image/jpeg",
56
+ ".gif": "image/gif",
57
+ ".webp": "image/webp",
58
+ ".bmp": "image/bmp",
59
+ ".tiff": "image/tiff",
60
+ ".tif": "image/tiff",
61
+ }
62
+
63
+ #: Appended to every failure placeholder. Without it the model is handed a
64
+ #: bracketed note about an image it cannot see and improvises — in practice it
65
+ #: writes a script to decode the file and read raw pixel values, which answers
66
+ #: nothing and costs a tool round-trip. Saying so explicitly stops that.
67
+ _NO_PIXEL_ARCHAEOLOGY = (
68
+ " Do not try to inspect the image yourself (no pixel decoding, no PIL/ffmpeg "
69
+ "scripts) — it cannot be read that way. Ask the user what it shows, or "
70
+ "suggest `/vision on` if their model can see images."
71
+ )
72
+
73
+ # Placeholder captions returned (instead of leaking an image) when vision fails.
74
+ _VISION_UNAVAILABLE = (
75
+ "[image: vision model unavailable — configure it in `/model` → `Vision`, "
76
+ "or run `/vision <provider> <model>`]"
77
+ )
78
+ _VISION_UNAVAILABLE += _NO_PIXEL_ARCHAEOLOGY
79
+ _VISION_FAILED = (
80
+ "[image: vision captioning failed — the configured vision_model rejected the "
81
+ "image or is unavailable]" + _NO_PIXEL_ARCHAEOLOGY
82
+ )
83
+ _VISION_EMPTY = "[image: vision model returned no description]" + _NO_PIXEL_ARCHAEOLOGY
84
+
85
+ #: How many consecutive images to skip captioning for after a hard failure.
86
+ #: A single transient failure (a cold Ollama model, a momentary network blip)
87
+ #: must not disable image reading for the rest of the session — the old
88
+ #: permanent latch did exactly that. After this many skips the vision model is
89
+ #: retried, so a recovered provider starts working again without a restart.
90
+ _VISION_FAILURE_COOLDOWN = 3
91
+ VISION_TOOL_TIMEOUT_SECONDS = 30
92
+ _VISION_TOOL_NOTE = (
93
+ "[An image is attached but was not sent to this text-only model. "
94
+ "Call VISION if you need to inspect its visual contents.]"
95
+ )
96
+
97
+
98
+ def _suffix_to_mime_type(suffix: str | None) -> str:
99
+ """Best-effort MIME type from a file suffix (defaults to ``image/png``)."""
100
+ if suffix:
101
+ return _MIME_BY_SUFFIX.get(suffix.lower(), "image/png")
102
+ return "image/png"
103
+
104
+
105
+ # ── Vision model (process-wide cache, shared by both entry points) ──────────
106
+
107
+ _vision_model_instance: BaseChatModel | None = None
108
+ _vision_model_errored: bool = False
109
+ _vision_failure_reason: str | None = None
110
+ #: Remaining images to skip before retrying the vision model after a failure.
111
+ #: Decremented on each caption attempt while > 0; when it reaches 0 the model is
112
+ #: retried. Replaces the old permanent latch (one failure killed vision for the
113
+ #: whole session).
114
+ _vision_cooldown_remaining: int = 0
115
+
116
+
117
+ def get_vision_model() -> BaseChatModel | None:
118
+ """Lazily create the configured vision model (cached process-wide).
119
+
120
+ Returns ``None`` when creation fails or the provider is unavailable. A
121
+ failure starts a short cooldown (see :data:`_VISION_FAILURE_COOLDOWN`) rather
122
+ than disabling vision permanently, so a transient problem self-heals.
123
+ """
124
+ global _vision_model_instance, _vision_model_errored # noqa: PLW0603
125
+ if _vision_model_errored:
126
+ return None
127
+ if _vision_model_instance is not None:
128
+ return _vision_model_instance
129
+ try:
130
+ from novacode_cli.config.model_create import create_model_from_config
131
+ from novacode_cli.config.nova_config import NovaConfig
132
+
133
+ cfg = NovaConfig().get_vision_model_config()
134
+ model = create_model_from_config(cfg["provider"], cfg["model"])
135
+ except Exception:
136
+ logger.exception("Failed to create vision model")
137
+ _vision_model_errored = True
138
+ return None
139
+ if model is None:
140
+ logger.warning("Vision model unavailable (missing provider/API key?)")
141
+ _vision_model_errored = True
142
+ return None
143
+ _vision_model_instance = model
144
+ logger.info("Vision captioning model ready")
145
+ return model
146
+
147
+
148
+ def _mark_vision_errored() -> None:
149
+ """Start a short cooldown after a hard failure (not a permanent disable).
150
+
151
+ The vision model is dropped and skipped for the next
152
+ :data:`_VISION_FAILURE_COOLDOWN` images, then retried — so a transient
153
+ failure (cold model, network blip) recovers without a Nova restart.
154
+ """
155
+ global _vision_model_instance, _vision_cooldown_remaining # noqa: PLW0603
156
+ _vision_model_instance = None
157
+ _vision_cooldown_remaining = _VISION_FAILURE_COOLDOWN
158
+
159
+
160
+ def reset_vision_model_cache() -> None:
161
+ """Forget a cached model/error after its provider or model is reconfigured."""
162
+ global _vision_model_instance, _vision_model_errored, _vision_cooldown_remaining, _vision_failure_reason # noqa: PLW0603
163
+ _vision_model_instance = None
164
+ _vision_model_errored = False
165
+ _vision_cooldown_remaining = 0
166
+ _vision_failure_reason = None
167
+
168
+
169
+ def _vision_in_cooldown() -> bool:
170
+ """True while the vision model is being skipped after a recent failure."""
171
+ global _vision_cooldown_remaining # noqa: PLW0603
172
+ if _vision_cooldown_remaining > 0:
173
+ _vision_cooldown_remaining -= 1
174
+ return True
175
+ return False
176
+
177
+
178
+ async def caption_images(image_urls: list[str], task_hint: str = "") -> str:
179
+ """Caption one or more images to text via the vision model (out-of-band).
180
+
181
+ Never raises and never returns image data — on any failure it returns a
182
+ short placeholder string so the conversation continues as text-only.
183
+
184
+ Args:
185
+ image_urls: ``data:<mime>;base64,<…>`` URLs (or http(s) URLs).
186
+ task_hint: The user's current request, so the caption targets the task.
187
+ """
188
+ global _vision_failure_reason # noqa: PLW0603
189
+ if not image_urls:
190
+ return ""
191
+ if _vision_in_cooldown():
192
+ reason = _vision_failure_reason or "temporarily paused after a previous failure"
193
+ return f"{_VISION_FAILED} ({reason})"
194
+ model = get_vision_model()
195
+ if model is None:
196
+ try:
197
+ from novacode_cli.config.nova_config import NovaConfig
198
+
199
+ cfg = NovaConfig().get_vision_model_config()
200
+ selected = f"{cfg['provider']}/{cfg['model']}"
201
+ except Exception: # noqa: BLE001 — this is already a fail-open path
202
+ selected = "the configured vision model"
203
+ return f"{_VISION_UNAVAILABLE} (could not initialize {selected})"
204
+ try:
205
+ from novacode_cli.prompts import render_template
206
+
207
+ prompt = render_template("vision_caption.jinja", task_hint=(task_hint or "").strip())
208
+ human_content: list[dict[str, Any]] = [
209
+ {"type": "text", "text": prompt},
210
+ *[{"type": "image_url", "image_url": {"url": u}} for u in image_urls],
211
+ ]
212
+ async with asyncio.timeout(VISION_TOOL_TIMEOUT_SECONDS):
213
+ resp = await model.ainvoke(
214
+ [HumanMessage(content=human_content)],
215
+ config={
216
+ "run_name": "nova_vision_caption",
217
+ "tags": ["nova", "vision", "caption"],
218
+ "metadata": {"nova_oob": True},
219
+ },
220
+ )
221
+ raw = getattr(resp, "content", "")
222
+ text = (raw if isinstance(raw, str) else str(raw)).strip()
223
+ except TimeoutError:
224
+ _vision_failure_reason = f"request timed out after {VISION_TOOL_TIMEOUT_SECONDS} seconds"
225
+ _mark_vision_errored()
226
+ return f"{_VISION_FAILED} ({_vision_failure_reason})"
227
+ except Exception as e: # noqa: BLE001 — never crash a turn over a caption
228
+ # A KNOWN provider problem (region opt-in, auth, quota) means the vision
229
+ # model is simply unavailable — log one clean line, not a stack trace, so
230
+ # a region-restricted auxiliary model doesn't spam tracebacks every image.
231
+ from novacode_cli.errors import friendly_model_error
232
+
233
+ notice = friendly_model_error(e)
234
+ if notice:
235
+ reason = notice.splitlines()[0]
236
+ logger.warning("Vision captioning unavailable: %s", reason)
237
+ else:
238
+ reason = str(e).splitlines()[0] if str(e) else "unknown model error"
239
+ logger.warning("Vision captioning failed", exc_info=True)
240
+ _vision_failure_reason = reason
241
+ _mark_vision_errored()
242
+ return f"{_VISION_FAILED} ({reason})"
243
+ _vision_failure_reason = None
244
+ return text or _VISION_EMPTY
245
+
246
+
247
+ # ── Image extraction / stripping helpers ────────────────────────────────────
248
+
249
+
250
+ def _extract_image_urls(msg: AnyMessage) -> list[str]:
251
+ """Extract ``data:...;base64,...`` URLs from a message's ``content_blocks``.
252
+
253
+ deepagents' ``read_file`` stores image data as ``content_blocks`` of
254
+ ``type == "image"`` with a ``base64`` field; the MIME type falls back to the
255
+ file suffix recorded in ``additional_kwargs['read_file_path']``.
256
+ """
257
+ urls: list[str] = []
258
+ content_blocks = getattr(msg, "content_blocks", None) or []
259
+ for block in content_blocks:
260
+ if isinstance(block, dict) and block.get("type") == "image":
261
+ b64 = block.get("base64", "")
262
+ mime = block.get("mime_type", None)
263
+ if not mime:
264
+ rfp = getattr(msg, "additional_kwargs", {}).get("read_file_path", "")
265
+ suffix = None
266
+ if rfp:
267
+ from pathlib import Path
268
+
269
+ suffix = Path(rfp).suffix
270
+ mime = _suffix_to_mime_type(suffix)
271
+ if b64:
272
+ urls.append(f"data:{mime};base64,{b64}")
273
+ return urls
274
+
275
+
276
+ def _collect_image_urls(msg: AnyMessage) -> list[str]:
277
+ """Collect image URLs from BOTH ``content_blocks`` and a list ``content``.
278
+
279
+ Covers read_file (image blocks in ``content_blocks``) and pasted multimodal
280
+ messages (``{"type": "image_url", "image_url": {"url": …}}`` in ``content``).
281
+ Order-preserving and de-duplicated.
282
+ """
283
+ urls: list[str] = list(_extract_image_urls(msg))
284
+ content = getattr(msg, "content", None)
285
+ if isinstance(content, list):
286
+ for block in content:
287
+ if not isinstance(block, dict):
288
+ continue
289
+ btype = block.get("type")
290
+ if btype == "image_url":
291
+ image_url = block.get("image_url")
292
+ url = image_url.get("url") if isinstance(image_url, dict) else image_url
293
+ if url:
294
+ urls.append(url)
295
+ elif btype == "image" and block.get("base64"):
296
+ mime = block.get("mime_type") or "image/png"
297
+ urls.append(f"data:{mime};base64,{block['base64']}")
298
+ seen: set[str] = set()
299
+ out: list[str] = []
300
+ for url in urls:
301
+ if url not in seen:
302
+ seen.add(url)
303
+ out.append(url)
304
+ return out
305
+
306
+
307
+ def _strip_image_content(msg: AnyMessage) -> AnyMessage:
308
+ """Return *msg* stripped of any image content blocks.
309
+
310
+ Fast path: returns the same object when no image data is present (zero
311
+ allocation for the common text-only case). Used as a defensive safety net so
312
+ no residual image reaches the main model.
313
+ """
314
+ content_blocks = getattr(msg, "content_blocks", None) or []
315
+ has_image_in_blocks = any(
316
+ isinstance(b, dict) and b.get("type") == "image" for b in content_blocks
317
+ )
318
+ raw_content = msg.content
319
+ has_image_in_content = isinstance(raw_content, list) and any(
320
+ isinstance(b, dict) and b.get("type") in ("image", "image_url") for b in raw_content
321
+ )
322
+ if not has_image_in_blocks and not has_image_in_content:
323
+ return msg # fast path — same object
324
+
325
+ if isinstance(raw_content, list):
326
+ clean_blocks = [
327
+ b
328
+ for b in raw_content
329
+ if not (isinstance(b, dict) and b.get("type") in ("image", "image_url"))
330
+ ]
331
+ if isinstance(msg, ToolMessage):
332
+ text_parts = [
333
+ b.get("text", "")
334
+ for b in clean_blocks
335
+ if isinstance(b, dict) and b.get("type") == "text"
336
+ ]
337
+ new_content: str | list = " ".join(text_parts) if text_parts else ""
338
+ else:
339
+ new_content = clean_blocks if clean_blocks else ""
340
+ else:
341
+ new_content = raw_content
342
+
343
+ # Keep a useful breadcrumb for the text-only model, but never send the
344
+ # encoded image itself. The original message remains in graph state for the
345
+ # VISION tool to read only if the model chooses to call it.
346
+ if isinstance(msg, HumanMessage):
347
+ text_content = (
348
+ " ".join(
349
+ b.get("text", "")
350
+ for b in new_content
351
+ if isinstance(b, dict) and b.get("type") == "text"
352
+ )
353
+ if isinstance(new_content, list)
354
+ else str(new_content or "")
355
+ )
356
+ if _VISION_TOOL_NOTE not in text_content:
357
+ if isinstance(new_content, list):
358
+ new_content.append({"type": "text", "text": _VISION_TOOL_NOTE})
359
+ else:
360
+ new_content = f"{text_content}\n\n{_VISION_TOOL_NOTE}".strip()
361
+ elif isinstance(msg, ToolMessage):
362
+ text_content = (
363
+ " ".join(
364
+ b.get("text", "")
365
+ for b in new_content
366
+ if isinstance(b, dict) and b.get("type") == "text"
367
+ )
368
+ if isinstance(new_content, list)
369
+ else str(new_content or "")
370
+ )
371
+ notice = "[Image content omitted; call VISION to inspect it if needed.]"
372
+ new_content = f"{text_content}\n{notice}".strip()
373
+
374
+ kwargs: dict[str, Any] = {
375
+ "content": new_content,
376
+ "additional_kwargs": getattr(msg, "additional_kwargs", {}),
377
+ }
378
+ if isinstance(msg, HumanMessage):
379
+ return HumanMessage(**kwargs)
380
+ if isinstance(msg, AIMessage):
381
+ return AIMessage(**kwargs)
382
+ if isinstance(msg, ToolMessage):
383
+ return ToolMessage(
384
+ **kwargs,
385
+ name=getattr(msg, "name", None),
386
+ tool_call_id=getattr(msg, "tool_call_id", ""),
387
+ artifact=getattr(msg, "artifact", None),
388
+ )
389
+ return msg
390
+
391
+
392
+ def _strip_all_images(messages: list[AnyMessage]) -> list[AnyMessage]:
393
+ """Return *messages* with all image blocks stripped (copy-on-write).
394
+
395
+ Returns the original list (by identity) when nothing needs stripping.
396
+ """
397
+ needs_strip = False
398
+ for i, msg in enumerate(messages):
399
+ stripped = _strip_image_content(msg)
400
+ if stripped is not msg:
401
+ if not needs_strip:
402
+ messages = list(messages)
403
+ needs_strip = True
404
+ messages[i] = stripped
405
+ return messages
406
+
407
+
408
+ #: Formats every vision API accepts. Ollama rejects the rest ("invalid image
409
+ #: input"), and one such block in history fails every later call.
410
+ _PORTABLE_IMAGE_TYPES = frozenset({"image/png", "image/jpeg"})
411
+
412
+
413
+ @functools.lru_cache(maxsize=32)
414
+ def _as_jpeg(data: str) -> str:
415
+ from PIL import Image
416
+
417
+ from novacode_cli.image_utils import ImageProcessor
418
+
419
+ image = Image.open(io.BytesIO(base64.b64decode(data)))
420
+ return base64.b64encode(ImageProcessor.optimize_image(image, output_format="jpeg")).decode()
421
+
422
+
423
+ def _portable_images(messages: list[AnyMessage]) -> list[AnyMessage]:
424
+ """Re-encode WebP/GIF/BMP/TIFF image blocks as JPEG (copy-on-write)."""
425
+ out = messages
426
+ for i, msg in enumerate(messages):
427
+ if not isinstance(msg.content, list):
428
+ continue
429
+ blocks = list(msg.content)
430
+ for j, block in enumerate(blocks):
431
+ if not (
432
+ isinstance(block, dict)
433
+ and block.get("type") == "image"
434
+ and isinstance(block.get("base64"), str)
435
+ and block.get("mime_type") not in _PORTABLE_IMAGE_TYPES
436
+ ):
437
+ continue
438
+ try:
439
+ jpeg = _as_jpeg(block["base64"])
440
+ blocks[j] = {**block, "base64": jpeg, "mime_type": "image/jpeg"}
441
+ except Exception: # noqa: BLE001 — unreadable: send as is, the provider decides
442
+ logger.warning("could not convert %s", block.get("mime_type"), exc_info=True)
443
+ if blocks != msg.content:
444
+ if out is messages:
445
+ out = list(messages)
446
+ out[i] = msg.model_copy(update={"content": blocks})
447
+ return out
448
+
449
+
450
+ def _with_portable_images(request: ModelRequest) -> ModelRequest:
451
+ messages = _portable_images(request.messages)
452
+ return request if messages is request.messages else request.override(messages=messages)
453
+
454
+
455
+ def _latest_user_text(messages: list[AnyMessage]) -> str:
456
+ """Return the newest HumanMessage's text (capped), for the caption task hint."""
457
+ for msg in reversed(messages or []):
458
+ if not isinstance(msg, HumanMessage):
459
+ continue
460
+ content = msg.content
461
+ if isinstance(content, str):
462
+ return content[:500]
463
+ if isinstance(content, list):
464
+ texts = [
465
+ b.get("text", "")
466
+ for b in content
467
+ if isinstance(b, dict) and b.get("type") == "text"
468
+ ]
469
+ return " ".join(t for t in texts if t)[:500]
470
+ return ""
471
+
472
+
473
+ class VisionCaptionMiddleware(AgentMiddleware):
474
+ """Route images safely for text-only and multimodal main models.
475
+
476
+ When ``main_model_supports_images`` is ``True`` (a multimodal main model),
477
+ this middleware is a **pass-through**: images flow straight to the main
478
+ model, which reads them directly. Captioning and stripping are skipped.
479
+
480
+ When it is ``False``, image data stays in graph state for the ``VISION`` tool
481
+ and is stripped from model requests with a note inviting the model to invoke
482
+ that tool. The main model never receives image blocks and never changes.
483
+ """
484
+
485
+ def __init__(self, main_model_supports_images: bool = False) -> None: # noqa: FBT001, FBT002
486
+ """Create the middleware.
487
+
488
+ Args:
489
+ main_model_supports_images: ``True`` when the main model can accept
490
+ image input, in which case images are passed through untouched.
491
+ ``False`` (default) preserves the on-demand VISION tool path for
492
+ text-only models.
493
+ """
494
+ super().__init__()
495
+ self.main_model_supports_images = main_model_supports_images
496
+
497
+ @staticmethod
498
+ def get_vision_model() -> BaseChatModel | None:
499
+ """Expose the shared vision model accessor (back-compat / tests)."""
500
+ return get_vision_model()
501
+
502
+ # -- tool path: retain image results in graph state ----------------------
503
+
504
+ async def awrap_tool_call(
505
+ self,
506
+ request: ToolCallRequest,
507
+ handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
508
+ ) -> ToolMessage | Command:
509
+ """Leave image results in graph state; defer inspection to VISION."""
510
+ return await handler(request)
511
+
512
+ def wrap_tool_call(
513
+ self,
514
+ request: ToolCallRequest,
515
+ handler: Callable[[ToolCallRequest], ToolMessage | Command],
516
+ ) -> ToolMessage | Command:
517
+ """Synchronous pass-through; image inspection is handled by VISION."""
518
+ return handler(request)
519
+
520
+ # -- model path: strip-only safety net ----------------------------------
521
+
522
+ async def awrap_model_call(
523
+ self,
524
+ request: ModelRequest,
525
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
526
+ ) -> ModelResponse:
527
+ """Strip residual image blocks before a text-only main model.
528
+
529
+ Pass-through when the main model is multimodal — images are left intact
530
+ for the model to read.
531
+ """
532
+ if self.main_model_supports_images:
533
+ return await handler(_with_portable_images(request))
534
+ return await handler(
535
+ request.override(messages=_strip_all_images(request.messages))
536
+ )
537
+
538
+ def wrap_model_call(
539
+ self,
540
+ request: ModelRequest,
541
+ handler: Callable[[ModelRequest], ModelResponse],
542
+ ) -> ModelResponse:
543
+ """Synchronous variant of the strip safety net."""
544
+ if self.main_model_supports_images:
545
+ return handler(_with_portable_images(request))
546
+ return handler(request.override(messages=_strip_all_images(request.messages)))
547
+
548
+
549
+ def build_vision_tool():
550
+ """Create the on-demand VISION tool for a text-only main model.
551
+
552
+ Image bytes remain in graph state but are omitted from the main model's
553
+ request. This tool selects the most recent image-bearing message and sends
554
+ it to the independently configured vision model only after the main model
555
+ explicitly calls it.
556
+ """
557
+ @tool(
558
+ "VISION",
559
+ description=(
560
+ "Inspect the most recently attached image using the separately configured "
561
+ "vision model. Call only when visual details are needed; returns a textual "
562
+ "description and never changes the main model."
563
+ ),
564
+ )
565
+ async def vision(runtime: ToolRuntime) -> str:
566
+ messages = runtime.state.get("messages", [])
567
+ # Do not let a later turn accidentally inspect an image attached in an
568
+ # earlier one. read_file images from this turn follow the latest user
569
+ # message, so they remain eligible alongside pasted attachments.
570
+ turn_start = next(
571
+ (i for i in range(len(messages) - 1, -1, -1) if isinstance(messages[i], HumanMessage)),
572
+ 0,
573
+ )
574
+ current_turn = messages[turn_start:]
575
+ for message in reversed(current_turn):
576
+ image_urls = _collect_image_urls(message)
577
+ if not image_urls:
578
+ continue
579
+ task_hint = _latest_user_text(current_turn)
580
+ return await caption_images(image_urls, task_hint)
581
+ return "VISION could not find an attached or recently read image in this turn."
582
+
583
+ return vision
584
+
585
+
586
+ def is_vision_failure(caption: str) -> bool:
587
+ """True when *caption* is a failure placeholder rather than a description.
588
+
589
+ The placeholders are ordinary (truthy) strings, so a caller that only tests
590
+ ``if captions`` will treat a failed caption as a successful one. Use this to
591
+ tell them apart.
592
+ """
593
+ return caption.startswith((_VISION_FAILED, _VISION_UNAVAILABLE, _VISION_EMPTY))
594
+
595
+
596
+ __all__ = [
597
+ "VisionCaptionMiddleware",
598
+ "VISION_TOOL_TIMEOUT_SECONDS",
599
+ "build_vision_tool",
600
+ "caption_images",
601
+ "get_vision_model",
602
+ "is_vision_failure",
603
+ "reset_vision_model_cache",
604
+ ]