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,677 @@
1
+ """Per-turn LLM usage attribution tree.
2
+
3
+ Every model in Nova is built through :func:`novacode_cli.config.model_create.build_chat_model`;
4
+ a LangChain callback handler attached there records each call's token usage into
5
+ the *current usage scope* (a contextvar stack). Loops push scopes around their
6
+ work, so a turn's usage forms a tree:
7
+
8
+ turn
9
+ ├── main (base agent stream)
10
+ ├── verification (inline verifier grading + re-drive attempts)
11
+ │ └── main
12
+ ├── goal (autonomous re-drives)
13
+ │ └── verification
14
+ │ └── main
15
+ ├── compaction (/compact + auto-compact summarization)
16
+ └── hermes (out-of-band self-review)
17
+
18
+ The tree is created by the shared event loop or session worker, populated by the callback, and
19
+ persisted to the durable store at turn end. ``/usage`` renders it.
20
+
21
+ Design notes:
22
+
23
+ - ``usage_scope`` is a *sync* context manager — it only manipulates a
24
+ contextvar, so it works across ``await`` points inside async generators.
25
+ - Both ``usage_scope`` and the callback are no-ops when no tree is active, so
26
+ tests and non-turn model calls (doctor, model switching) are unaffected.
27
+ - Callback attribution is captured at call start, including worker threads
28
+ with propagated context, so concurrent completions keep their owning scope.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ # Callback signatures follow LangChain's heterogeneous framework interface.
34
+ # ruff: noqa: ANN401, ARG002
35
+ import contextvars
36
+ import logging
37
+ import re
38
+ import time
39
+ import uuid
40
+ from contextlib import contextmanager
41
+ from dataclasses import dataclass, field
42
+ from threading import RLock
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ from langchain_core.callbacks import BaseCallbackHandler
46
+
47
+ if TYPE_CHECKING:
48
+ from collections.abc import AsyncIterator, Iterator
49
+
50
+ from langchain_core.language_models import BaseChatModel
51
+
52
+ logger = logging.getLogger("nova.tracking.usage_tree")
53
+
54
+ #: Durable-store namespace for per-turn usage trees.
55
+ _USAGE_NS = ("nova", "usage_trees")
56
+ _MAX_REQUEST_RECORDS = 256
57
+
58
+ #: Per-1M-token USD prices (input, output) for known models — most-specific
59
+ #: catalog match wins; unlisted variants have unavailable prices (tokens still shown).
60
+ _PRICE_PER_M: dict[str, tuple[float, float]] = {
61
+ "claude-opus-4": (15.0, 75.0),
62
+ "claude-sonnet-4-5": (3.0, 15.0),
63
+ "claude-haiku-4-5": (1.0, 5.0),
64
+ "gpt-5": (1.25, 10.0),
65
+ "gpt-5-mini": (0.25, 2.0),
66
+ "gemini-3-pro": (2.0, 12.0),
67
+ "gemini-2.5": (1.25, 10.0),
68
+ "deepseek": (0.27, 1.10),
69
+ }
70
+
71
+
72
+ # ═══════════════════════════════════════════════════════════════════════════
73
+ # Tree
74
+ # ═══════════════════════════════════════════════════════════════════════════
75
+
76
+
77
+ @dataclass
78
+ class UsageNode:
79
+ """One scope's accumulated usage; children form the attribution tree."""
80
+
81
+ scope: str
82
+ model: str = ""
83
+ input_tokens: int = 0
84
+ output_tokens: int = 0
85
+ cache_read_tokens: int = 0
86
+ cache_creation_tokens: int = 0
87
+ calls: int = 0
88
+ unavailable_calls: int = 0
89
+ cache_observed_input_tokens: int = 0
90
+ cache_observed_read_tokens: int = 0
91
+ model_usage: dict[str, dict[str, int]] = field(default_factory=dict)
92
+ children: list[UsageNode] = field(default_factory=list)
93
+ parent: UsageNode | None = field(default=None, repr=False, compare=False)
94
+
95
+ def add(self, model: str, usage: dict[str, int]) -> None:
96
+ """Accumulate one model call's usage into this node."""
97
+ if self.calls and not self.model_usage:
98
+ self.model_usage[self.model] = {
99
+ "input_tokens": self.input_tokens,
100
+ "output_tokens": self.output_tokens,
101
+ }
102
+ if model:
103
+ self.model = model
104
+ self.input_tokens += int(usage.get("input_tokens", 0))
105
+ self.output_tokens += int(usage.get("output_tokens", 0))
106
+ self.cache_read_tokens += int(usage.get("cache_read_tokens", 0))
107
+ self.cache_creation_tokens += int(usage.get("cache_creation_tokens", 0))
108
+ if "cache_read_tokens" in usage and "input_tokens" in usage:
109
+ self.cache_observed_input_tokens += int(usage.get("input_tokens", 0))
110
+ self.cache_observed_read_tokens += int(usage["cache_read_tokens"])
111
+ self.calls += 1
112
+ if not usage:
113
+ self.unavailable_calls += 1
114
+ totals = self.model_usage.setdefault(model, {"input_tokens": 0, "output_tokens": 0})
115
+ totals["input_tokens"] += int(usage.get("input_tokens", 0))
116
+ totals["output_tokens"] += int(usage.get("output_tokens", 0))
117
+
118
+ def cost(self, *, recursive: bool = False) -> float | None:
119
+ """Estimate using each observed model; missing usage or prices stay unknown."""
120
+ if self.unavailable_calls:
121
+ return None
122
+ cost = 0.0
123
+ models = self.model_usage or (
124
+ {
125
+ self.model: {
126
+ "input_tokens": self.input_tokens,
127
+ "output_tokens": self.output_tokens,
128
+ }
129
+ }
130
+ if self.calls or self.input_tokens or self.output_tokens
131
+ else {}
132
+ )
133
+ for model, usage in models.items():
134
+ value = estimate_cost(usage["input_tokens"], usage["output_tokens"], model)
135
+ if value is None:
136
+ return None
137
+ cost += value
138
+ if recursive:
139
+ for child in self.children:
140
+ value = child.cost(recursive=True)
141
+ if value is None:
142
+ return None
143
+ cost += value
144
+ return cost
145
+
146
+ def totals(self) -> dict[str, int]:
147
+ """Recursive totals (own + all descendants)."""
148
+ out = {
149
+ "input_tokens": self.input_tokens,
150
+ "output_tokens": self.output_tokens,
151
+ "cache_read_tokens": self.cache_read_tokens,
152
+ "cache_creation_tokens": self.cache_creation_tokens,
153
+ "calls": self.calls,
154
+ "cache_observed_input_tokens": self.cache_observed_input_tokens,
155
+ "cache_observed_read_tokens": self.cache_observed_read_tokens,
156
+ }
157
+ for child in self.children:
158
+ sub = child.totals()
159
+ for key in out:
160
+ out[key] += sub[key]
161
+ return out
162
+
163
+ def to_dict(self) -> dict[str, Any]:
164
+ """Serializable form (no parent backrefs)."""
165
+ return {
166
+ "scope": self.scope,
167
+ "model": self.model,
168
+ "input_tokens": self.input_tokens,
169
+ "output_tokens": self.output_tokens,
170
+ "cache_read_tokens": self.cache_read_tokens,
171
+ "cache_creation_tokens": self.cache_creation_tokens,
172
+ "calls": self.calls,
173
+ "children": [c.to_dict() for c in self.children],
174
+ "unavailable_calls": self.unavailable_calls,
175
+ "cache_observed_input_tokens": self.cache_observed_input_tokens,
176
+ "cache_observed_read_tokens": self.cache_observed_read_tokens,
177
+ "model_usage": {model: dict(usage) for model, usage in self.model_usage.items()},
178
+ }
179
+
180
+ @classmethod
181
+ def from_dict(cls, data: dict[str, Any]) -> UsageNode:
182
+ """Rehydrate a node (and its children) from :meth:`to_dict` output."""
183
+ node = cls(
184
+ scope=data["scope"],
185
+ model=data.get("model", ""),
186
+ input_tokens=data.get("input_tokens", 0),
187
+ output_tokens=data.get("output_tokens", 0),
188
+ cache_read_tokens=data.get("cache_read_tokens", 0),
189
+ cache_creation_tokens=data.get("cache_creation_tokens", 0),
190
+ calls=data.get("calls", 0),
191
+ unavailable_calls=data.get("unavailable_calls", 0),
192
+ cache_observed_input_tokens=data.get("cache_observed_input_tokens", 0),
193
+ cache_observed_read_tokens=data.get("cache_observed_read_tokens", 0),
194
+ model_usage={key: dict(value) for key, value in data.get("model_usage", {}).items()},
195
+ )
196
+ for child in data.get("children", []):
197
+ node.children.append(cls.from_dict(child))
198
+ return node
199
+
200
+
201
+ class UsageTree:
202
+ """A per-turn usage tree rooted at a ``"turn"`` node."""
203
+
204
+ def __init__(self, thread_id: str, started_at: float | None = None) -> None:
205
+ """Create an empty tree for ``thread_id`` (root scope ``"turn"``)."""
206
+ self.thread_id = thread_id
207
+ self.started_at = started_at if started_at is not None else time.time()
208
+ self.record_id = uuid.uuid4().hex
209
+ self.root = UsageNode("turn")
210
+ self._nodes: dict[tuple[str, ...], UsageNode] = {("turn",): self.root}
211
+ self.requests: list[dict[str, Any]] = []
212
+ self.requests_aggregated = 0
213
+
214
+ def record_request(self, diagnostic: dict[str, Any]) -> None:
215
+ """Retain at most 256 content-free call records; totals live in nodes."""
216
+ if len(self.requests) < _MAX_REQUEST_RECORDS:
217
+ self.requests.append(diagnostic)
218
+ else:
219
+ self.requests_aggregated += 1
220
+
221
+ def node_for(self, path: tuple[str, ...]) -> UsageNode:
222
+ """Get-or-create the node at ``path`` (e.g. ``("verification", "main")``).
223
+
224
+ Links parents as needed.
225
+ """
226
+ node = self._nodes.get(path)
227
+ if node is not None:
228
+ return node
229
+ parent = self.node_for(path[:-1]) if len(path) > 1 else self.root
230
+ node = UsageNode(path[-1], parent=parent)
231
+ parent.children.append(node)
232
+ self._nodes[path] = node
233
+ return node
234
+
235
+ def record(self, path: tuple[str, ...], model: str, usage: dict[str, int]) -> None:
236
+ """Accumulate one call's usage into the node at ``path``."""
237
+ self.node_for(path).add(model, usage)
238
+
239
+ def to_dict(self) -> dict[str, Any]:
240
+ """Serializable form (thread id, start time, root node)."""
241
+ return {
242
+ "thread_id": self.thread_id,
243
+ "started_at": self.started_at,
244
+ "record_id": self.record_id,
245
+ "root": self.root.to_dict(),
246
+ "requests": [dict(record) for record in self.requests],
247
+ "requests_aggregated": self.requests_aggregated,
248
+ }
249
+
250
+ @classmethod
251
+ def from_dict(cls, data: dict[str, Any]) -> UsageTree:
252
+ """Rehydrate a tree from :meth:`to_dict` output (rebuilds the index)."""
253
+ tree = cls(data["thread_id"], started_at=data.get("started_at"))
254
+ tree.record_id = data.get("record_id", tree.record_id)
255
+ tree.root = UsageNode.from_dict(data["root"])
256
+ tree.requests = [dict(record) for record in data.get("requests", [])[:_MAX_REQUEST_RECORDS]]
257
+ tree.requests_aggregated = data.get("requests_aggregated", 0)
258
+ # Rebuild the path index from the rehydrated tree.
259
+ tree._nodes = {("turn",): tree.root}
260
+
261
+ def _index(node: UsageNode, path: tuple[str, ...]) -> None:
262
+ tree._nodes[path[1:]] = node # relative path (drop the "turn" root)
263
+ for child in node.children:
264
+ child.parent = node
265
+ _index(child, (*path, child.scope))
266
+
267
+ _index(tree.root, ("turn",))
268
+ return tree
269
+
270
+ def render(self) -> str:
271
+ """Indented tree with tokens + estimated cost per node."""
272
+ lines: list[str] = []
273
+ total = self.root.totals()
274
+ lines.append(
275
+ f"Turn {time.strftime('%H:%M:%S', time.localtime(self.started_at))} "
276
+ f"(thread {self.thread_id})"
277
+ )
278
+
279
+ def _walk(node: UsageNode, depth: int) -> None:
280
+ cost = node.cost()
281
+ cost_text = "unknown" if cost is None else f"${cost:,.2f}"
282
+ indent = " " * depth
283
+ lines.append(
284
+ f"{indent}{node.scope:<14} "
285
+ f"{node.input_tokens:>9,} in {node.output_tokens:>7,} out "
286
+ f"{node.calls:>3} calls {cost_text}"
287
+ )
288
+ for child in node.children:
289
+ _walk(child, depth + 1)
290
+
291
+ for child in self.root.children:
292
+ _walk(child, 1)
293
+ cost = self.root.cost(recursive=True)
294
+ cost_text = "unknown" if cost is None else f"${cost:,.2f}"
295
+ lines.append(
296
+ f"{'total':<14} "
297
+ f"{total['input_tokens']:>9,} in {total['output_tokens']:>7,} out "
298
+ f"{total['calls']:>3} calls {cost_text}"
299
+ )
300
+ if total["cache_observed_input_tokens"]:
301
+ reuse = total["cache_observed_read_tokens"] / total["cache_observed_input_tokens"] * 100
302
+ lines.insert(
303
+ -1,
304
+ f"Cache reuse: {reuse:.1f}% of observed input tokens "
305
+ f"({total['cache_observed_input_tokens']:,}/{total['input_tokens']:,} observed); "
306
+ f"{total['cache_read_tokens']:,} read, "
307
+ f"{total['cache_creation_tokens']:,} written. "
308
+ "Missing cache usage is unavailable. Costs are uncached estimates.",
309
+ )
310
+ elif total["input_tokens"]:
311
+ lines.insert(-1, "Cache reuse: unavailable. Costs are uncached estimates.")
312
+ attributed: dict[tuple[str, ...], dict[str, int]] = {}
313
+ for record in self.requests:
314
+ key = tuple(
315
+ str(record.get(k) or "unknown") for k in ("provider", "model", "purpose", "task_id")
316
+ )
317
+ subtotal = attributed.setdefault(
318
+ key, {"calls": 0, "input_tokens": 0, "cache_read_tokens": 0}
319
+ )
320
+ subtotal["calls"] += 1
321
+ for name in ("input_tokens", "cache_read_tokens"):
322
+ subtotal[name] += int(record.get("usage", {}).get(name, 0))
323
+ for (provider, model, purpose, task), subtotal in attributed.items():
324
+ lines.insert(
325
+ -1,
326
+ f" {provider}/{model} · {purpose} · task {task}: "
327
+ f"{subtotal['calls']} calls, {subtotal['input_tokens']:,} in, "
328
+ f"{subtotal['cache_read_tokens']:,} cache read",
329
+ )
330
+ if self.requests_aggregated:
331
+ lines.insert(
332
+ -1, f"{self.requests_aggregated} additional requests included in scope totals."
333
+ )
334
+ return "\n".join(lines)
335
+
336
+
337
+ # ═══════════════════════════════════════════════════════════════════════════
338
+ # Scope (contextvar stack)
339
+ # ═══════════════════════════════════════════════════════════════════════════
340
+
341
+ _current_tree: contextvars.ContextVar[UsageTree | None] = contextvars.ContextVar(
342
+ "nova_usage_tree", default=None
343
+ )
344
+ _scope_stack: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
345
+ "nova_usage_scope", default=()
346
+ )
347
+
348
+
349
+ def set_current_tree(tree: UsageTree | None) -> None:
350
+ """Bind the active tree for the current task (turn entry points)."""
351
+ _current_tree.set(tree)
352
+
353
+
354
+ def get_current_tree() -> UsageTree | None:
355
+ """The active tree, or ``None`` outside a turn."""
356
+ return _current_tree.get()
357
+
358
+
359
+ @contextmanager
360
+ def usage_scope(name: str) -> Iterator[None]:
361
+ """Push ``name`` onto the scope stack for the block's duration.
362
+
363
+ A no-op when no tree is active (tests, non-turn model calls). Nested scopes
364
+ form the tree's parent-child paths.
365
+ """
366
+ if _current_tree.get() is None:
367
+ yield
368
+ return
369
+ token = _scope_stack.set((*_scope_stack.get(), name))
370
+ try:
371
+ yield
372
+ finally:
373
+ _scope_stack.reset(token)
374
+
375
+
376
+ async def scoped_stream(source: AsyncIterator[Any], name: str) -> AsyncIterator[Any]:
377
+ """Attribute stream work without leaving a scope bound across UI yields."""
378
+ path = _scope_stack.get()
379
+ if _current_tree.get() is not None:
380
+ path = (*path, name)
381
+ try:
382
+ while True:
383
+ token = _scope_stack.set(path)
384
+ try:
385
+ item = await anext(source)
386
+ except StopAsyncIteration:
387
+ break
388
+ finally:
389
+ _scope_stack.reset(token)
390
+ yield item
391
+ finally:
392
+ # Closure can run in another task. A fresh token restores that task's
393
+ # scope while keeping model finalization attributed to this stream.
394
+ token = _scope_stack.set(path)
395
+ try:
396
+ close = getattr(source, "aclose", None)
397
+ if close is not None:
398
+ await close()
399
+ finally:
400
+ _scope_stack.reset(token)
401
+
402
+
403
+ # ═══════════════════════════════════════════════════════════════════════════
404
+ # Callback
405
+ # ═══════════════════════════════════════════════════════════════════════════
406
+
407
+
408
+ def _extract_usage(response: Any) -> dict[str, int]:
409
+ """Merge normalized and raw usage without conflating absent fields and zero."""
410
+ meta: dict = {}
411
+ raw: dict = {}
412
+ try:
413
+ message = response.generations[0][0].message
414
+ meta = getattr(message, "usage_metadata", None) or {}
415
+ if hasattr(meta, "model_dump"):
416
+ meta = meta.model_dump()
417
+ response_meta = getattr(message, "response_metadata", None) or {}
418
+ raw.update(response_meta.get("token_usage") or response_meta.get("usage") or {})
419
+ except (AttributeError, IndexError, TypeError):
420
+ pass
421
+ output = getattr(response, "llm_output", None) or {}
422
+ raw.update(output.get("token_usage") or output.get("usage") or {})
423
+
424
+ def first(*values: Any) -> int | None:
425
+ for value in values:
426
+ if value is not None:
427
+ try:
428
+ return max(0, int(value))
429
+ except (ValueError, TypeError, OverflowError):
430
+ continue
431
+ return None
432
+
433
+ details = meta.get("input_token_details") or {}
434
+ raw_details = raw.get("prompt_tokens_details") or raw.get("input_tokens_details") or {}
435
+ read = first(
436
+ details.get("cache_read"),
437
+ details.get("priority_cache_read"),
438
+ details.get("flex_cache_read"),
439
+ meta.get("cache_read_input_tokens"),
440
+ raw_details.get("cached_tokens"),
441
+ raw.get("cache_read_input_tokens"),
442
+ raw.get("prompt_cache_hit_tokens"),
443
+ )
444
+ write = first(
445
+ details.get("cache_creation"),
446
+ meta.get("cache_creation_input_tokens"),
447
+ raw.get("cache_creation_input_tokens"),
448
+ )
449
+ input_tokens = first(meta.get("input_tokens"))
450
+ if input_tokens is None:
451
+ input_tokens = first(raw.get("prompt_tokens"), raw.get("input_tokens"))
452
+ # Anthropic raw input excludes cached tokens; normalized input and OpenAI
453
+ # prompt_tokens already include them.
454
+ if (
455
+ input_tokens is not None
456
+ and "prompt_tokens" not in raw
457
+ and ("cache_read_input_tokens" in raw or "cache_creation_input_tokens" in raw)
458
+ ):
459
+ input_tokens += (first(raw.get("cache_read_input_tokens")) or 0) + (
460
+ first(raw.get("cache_creation_input_tokens")) or 0
461
+ )
462
+ if (
463
+ input_tokens is None
464
+ and "prompt_cache_hit_tokens" in raw
465
+ and ("prompt_cache_miss_tokens" in raw)
466
+ ):
467
+ input_tokens = (first(raw.get("prompt_cache_hit_tokens")) or 0) + (
468
+ first(raw.get("prompt_cache_miss_tokens")) or 0
469
+ )
470
+ result = {
471
+ "input_tokens": input_tokens,
472
+ "output_tokens": first(
473
+ meta.get("output_tokens"), raw.get("output_tokens"), raw.get("completion_tokens")
474
+ ),
475
+ "cache_read_tokens": read,
476
+ "cache_creation_tokens": write,
477
+ }
478
+ uncached = first(raw.get("prompt_cache_miss_tokens"))
479
+ if uncached is not None:
480
+ result["uncached_tokens"] = uncached
481
+ elif input_tokens is not None and read is not None and write is not None:
482
+ result["uncached_tokens"] = max(0, input_tokens - read - write)
483
+ return {key: value for key, value in result.items() if value is not None}
484
+
485
+
486
+ def _model_from_serialized(serialized: dict) -> str:
487
+ kwargs = serialized.get("kwargs", {}) or {}
488
+ return str(kwargs.get("model") or kwargs.get("model_name") or serialized.get("name") or "")
489
+
490
+
491
+ class UsageCallbackHandler(BaseCallbackHandler):
492
+ """Attribute calls to their starting task, including concurrent callbacks."""
493
+
494
+ def __init__(self) -> None:
495
+ """Keep active call attribution without retaining messages or credentials."""
496
+ super().__init__()
497
+ self._model_names: dict[Any, str] = {}
498
+ self._runs: dict[Any, dict[str, Any]] = {}
499
+ self._lock = RLock()
500
+
501
+ def on_chat_model_start(
502
+ self, serialized: dict, messages: Any, *, run_id: Any, **kwargs: Any
503
+ ) -> None:
504
+ """Capture chat calls without retaining their messages."""
505
+ self.on_llm_start(serialized, [], run_id=run_id, **kwargs)
506
+
507
+ def on_llm_start(self, serialized: dict, prompts: Any, *, run_id: Any, **kwargs: Any) -> None:
508
+ """Capture timing and attribution before context switches."""
509
+ from novacode_cli.tracking.request_metrics import (
510
+ request_diagnostic,
511
+ request_purpose,
512
+ task_correlation,
513
+ )
514
+
515
+ metadata = kwargs.get("metadata") or {}
516
+ model = str(
517
+ metadata.get("nova_model")
518
+ or metadata.get("ls_model_name")
519
+ or _model_from_serialized(serialized)
520
+ )
521
+ purpose = request_purpose.get()
522
+ if purpose == "unknown":
523
+ purpose = (_scope_stack.get() or (str(metadata.get("langgraph_node") or "unknown"),))[
524
+ -1
525
+ ]
526
+ with self._lock:
527
+ self._model_names[run_id] = model
528
+ self._runs[run_id] = {
529
+ "tree": _current_tree.get(),
530
+ "path": _scope_stack.get() or ("turn",),
531
+ "started": time.perf_counter(),
532
+ "model": model,
533
+ "provider": str(metadata.get("nova_provider") or metadata.get("ls_provider") or ""),
534
+ "purpose": str(metadata.get("nova_request_purpose") or purpose),
535
+ "task_id": str(metadata.get("nova_task_id") or task_correlation.get() or ""),
536
+ "first_token_ms": None,
537
+ **(request_diagnostic.get() or {}),
538
+ }
539
+
540
+ def on_llm_new_token(
541
+ self, token: str | list[str | dict[str, Any]], *, run_id: Any, **kwargs: Any
542
+ ) -> None:
543
+ """Observe first-token time only when the adapter emits a token."""
544
+ with self._lock:
545
+ run = self._runs.get(run_id)
546
+ if token and run is not None and run["first_token_ms"] is None:
547
+ run["first_token_ms"] = (time.perf_counter() - run["started"]) * 1000
548
+
549
+ def _finish(self, run_id: Any, usage: dict[str, int], outcome: str) -> None:
550
+ with self._lock:
551
+ self._model_names.pop(run_id, None)
552
+ run = self._runs.pop(run_id, None)
553
+ if run is None:
554
+ return
555
+ tree = run.pop("tree")
556
+ path = run.pop("path")
557
+ run["scope"] = list(path)
558
+ run["duration_ms"] = (time.perf_counter() - run.pop("started")) * 1000
559
+ run["outcome"] = outcome
560
+ run["usage"] = dict(usage)
561
+ if tree is not None:
562
+ tree.record(path, run["model"], usage)
563
+ if usage and ("input_tokens" not in usage or "output_tokens" not in usage):
564
+ tree.node_for(path).unavailable_calls += 1
565
+ tree.record_request(run)
566
+
567
+ def on_llm_end(self, response: Any, *, run_id: Any, **kwargs: Any) -> None:
568
+ """Record merged usage and release all in-flight attribution."""
569
+ with self._lock:
570
+ run = self._runs.get(run_id)
571
+ if run is not None and not run["model"]:
572
+ run["model"] = str(
573
+ (getattr(response, "llm_output", None) or {}).get("model_name") or ""
574
+ )
575
+ self._finish(run_id, _extract_usage(response), "success")
576
+
577
+ def on_llm_error(self, error: Any, *, run_id: Any, **kwargs: Any) -> None:
578
+ """Release failed/cancelled calls while retaining their attribution."""
579
+ self._finish(run_id, {}, "error")
580
+
581
+
582
+ _HANDLER = UsageCallbackHandler()
583
+
584
+
585
+ def attach_usage_callback(model: BaseChatModel) -> None:
586
+ """Append the shared usage handler to a model's default callbacks."""
587
+ existing = list(getattr(model, "callbacks", None) or [])
588
+ if _HANDLER not in existing:
589
+ model.callbacks = [*existing, _HANDLER]
590
+
591
+
592
+ # ═══════════════════════════════════════════════════════════════════════════
593
+ # Cost
594
+ # ═══════════════════════════════════════════════════════════════════════════
595
+
596
+
597
+ def estimate_cost(input_tokens: int, output_tokens: int, model: str = "") -> float | None:
598
+ """Estimated USD cost using the bundled table; unknown prices return None.
599
+
600
+ These are uncached list-price estimates, not a billing ledger. Provider
601
+ discounts, cache prices and unobserved SDK retry usage are unavailable.
602
+ """
603
+ if not model:
604
+ return None
605
+ name = model.casefold().rsplit("/", 1)[-1]
606
+ best_key = ""
607
+ best_price: tuple[float, float] | None = None
608
+ for key, price in _PRICE_PER_M.items():
609
+ # A family substring is not a price for every variant (e.g. Flash vs
610
+ # Pro). Accept only catalog names and their dated snapshot aliases.
611
+ if re.fullmatch(re.escape(key) + r"(?:-\d{4}-?\d{2}-?\d{2})?", name) and len(key) > len(
612
+ best_key
613
+ ):
614
+ best_key = key
615
+ best_price = price
616
+ if best_price is None:
617
+ return None
618
+ return (input_tokens / 1_000_000) * best_price[0] + (output_tokens / 1_000_000) * best_price[1]
619
+
620
+
621
+ # ═══════════════════════════════════════════════════════════════════════════
622
+ # Persistence + reporting
623
+ # ═══════════════════════════════════════════════════════════════════════════
624
+
625
+
626
+ async def persist_usage_tree(tree: UsageTree, store: Any = None) -> None:
627
+ """Persist a turn's tree to the durable store (best-effort, never raises)."""
628
+ try:
629
+ if store is None:
630
+ from novacode_cli.memory.store import get_durable_store
631
+
632
+ store = get_durable_store()
633
+ key = f"{tree.thread_id}:{tree.record_id}"
634
+ await store.aput(_USAGE_NS, key, tree.to_dict())
635
+ except Exception: # usage tracking must never break a turn
636
+ logger.exception("Failed to persist usage tree")
637
+
638
+
639
+ async def load_usage_trees(thread_id: str, store: Any = None, limit: int = 10) -> list[dict]:
640
+ """Recent usage trees for a thread, newest first (best-effort)."""
641
+ if limit <= 0:
642
+ return []
643
+ try:
644
+ if store is None:
645
+ from novacode_cli.memory.store import get_durable_store
646
+
647
+ store = get_durable_store()
648
+ trees: list[dict] = []
649
+ offset = 0
650
+ page_size = 100
651
+ # Stores do not guarantee ordering by the record's started_at. Search
652
+ # this thread in pages, retaining only the newest requested records.
653
+ while True:
654
+ items = await store.asearch(
655
+ _USAGE_NS,
656
+ filter={"thread_id": thread_id},
657
+ limit=page_size,
658
+ offset=offset,
659
+ )
660
+ trees.extend(
661
+ dict(item.value) for item in items if str(item.key).startswith(f"{thread_id}:")
662
+ )
663
+ trees.sort(key=lambda t: t.get("started_at", 0), reverse=True)
664
+ del trees[limit:]
665
+ if len(items) < page_size:
666
+ return trees
667
+ offset += len(items)
668
+ except Exception: # usage tracking must never break a turn
669
+ logger.exception("Failed to load usage trees")
670
+ return []
671
+
672
+
673
+ def format_usage_report(trees: list[dict]) -> str:
674
+ """Render persisted trees into a human-readable report."""
675
+ if not trees:
676
+ return "No usage data recorded yet for this session."
677
+ return "\n\n".join(UsageTree.from_dict(t).render() for t in trees)