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.
- novacode_cli/__init__.py +29 -0
- novacode_cli/__main__.py +6 -0
- novacode_cli/_lazy_heavy.py +99 -0
- novacode_cli/_version.py +3 -0
- novacode_cli/agent_stream.py +178 -0
- novacode_cli/agents/agent_file.py +250 -0
- novacode_cli/agents/agent_mailbox.py +120 -0
- novacode_cli/agents/approval_decisions.py +146 -0
- novacode_cli/agents/arg_repair.py +196 -0
- novacode_cli/agents/async_agents/__init__.py +6 -0
- novacode_cli/agents/async_agents/_config.py +213 -0
- novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
- novacode_cli/agents/async_agents/_specialist.py +84 -0
- novacode_cli/agents/async_agents/_test_commands.py +89 -0
- novacode_cli/agents/async_agents/_tools.py +432 -0
- novacode_cli/agents/async_agents/general_purpose.py +42 -0
- novacode_cli/agents/async_agents/langgraph.json +9 -0
- novacode_cli/agents/async_agents/research.py +22 -0
- novacode_cli/agents/async_context.py +303 -0
- novacode_cli/agents/async_on_demand.py +52 -0
- novacode_cli/agents/async_task_errors.py +75 -0
- novacode_cli/agents/async_workspace.py +27 -0
- novacode_cli/agents/attribution.py +165 -0
- novacode_cli/agents/browser_mode.py +119 -0
- novacode_cli/agents/btw_agent.py +71 -0
- novacode_cli/agents/caveman.py +352 -0
- novacode_cli/agents/caveman_support.py +370 -0
- novacode_cli/agents/code_mode.py +774 -0
- novacode_cli/agents/code_runtime.py +160 -0
- novacode_cli/agents/context_compaction.py +1339 -0
- novacode_cli/agents/core_agent.py +2710 -0
- novacode_cli/agents/default_subagents/__init__.py +24 -0
- novacode_cli/agents/default_subagents/async_subagents.py +296 -0
- novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
- novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
- novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
- novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
- novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
- novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
- novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
- novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
- novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
- novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
- novacode_cli/agents/default_subagents/prompt.py +88 -0
- novacode_cli/agents/default_subagents/subagents.py +138 -0
- novacode_cli/agents/dream_agent.py +93 -0
- novacode_cli/agents/edit_diagnostics.py +362 -0
- novacode_cli/agents/edit_fallback.py +62 -0
- novacode_cli/agents/handoff.py +166 -0
- novacode_cli/agents/invocation.py +152 -0
- novacode_cli/agents/model_budget.py +42 -0
- novacode_cli/agents/model_retry.py +113 -0
- novacode_cli/agents/model_router.py +420 -0
- novacode_cli/agents/openai_decisions.py +186 -0
- novacode_cli/agents/plan_agent/__init__.py +12 -0
- novacode_cli/agents/plan_agent/plan_agent.py +357 -0
- novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
- novacode_cli/agents/plan_execution.py +237 -0
- novacode_cli/agents/ponytail.py +66 -0
- novacode_cli/agents/prime.py +274 -0
- novacode_cli/agents/provider_caching.py +99 -0
- novacode_cli/agents/server_launcher.py +904 -0
- novacode_cli/agents/strict_tools.py +384 -0
- novacode_cli/agents/task_discipline.py +294 -0
- novacode_cli/agents/tool_call_repair.py +290 -0
- novacode_cli/agents/tool_offload.py +291 -0
- novacode_cli/agents/tool_search.py +806 -0
- novacode_cli/agents/tool_search_variants.py +106 -0
- novacode_cli/agents/tool_verdicts.py +1194 -0
- novacode_cli/agents/user_async_agents.py +312 -0
- novacode_cli/artifacts/__init__.py +10 -0
- novacode_cli/artifacts/registry.py +278 -0
- novacode_cli/artifacts/server.py +426 -0
- novacode_cli/assets/Nova.png +0 -0
- novacode_cli/assets/nova-avatar.png +0 -0
- novacode_cli/audio/__init__.py +301 -0
- novacode_cli/audio/capture.py +137 -0
- novacode_cli/audio/downloads.py +273 -0
- novacode_cli/audio/pipeline.py +391 -0
- novacode_cli/audio/playback.py +87 -0
- novacode_cli/audio/providers.py +208 -0
- novacode_cli/audio/speakable.py +78 -0
- novacode_cli/audio/stt.py +132 -0
- novacode_cli/audio/stt_deepgram.py +69 -0
- novacode_cli/audio/stt_elevenlabs.py +89 -0
- novacode_cli/audio/stt_parakeet.py +94 -0
- novacode_cli/audio/stt_whistle.py +61 -0
- novacode_cli/audio/summarize.py +92 -0
- novacode_cli/audio/tts.py +85 -0
- novacode_cli/audio/tts_kokoro.py +73 -0
- novacode_cli/audio/tts_orpheus.py +180 -0
- novacode_cli/audio/tts_pocket.py +131 -0
- novacode_cli/audio/tts_sano.py +69 -0
- novacode_cli/audio/vad.py +145 -0
- novacode_cli/backends/__init__.py +17 -0
- novacode_cli/backends/filesystem.py +894 -0
- novacode_cli/backends/native_search.py +92 -0
- novacode_cli/bootstrap/__init__.py +9 -0
- novacode_cli/bootstrap/env_snapshot.py +240 -0
- novacode_cli/bootstrap/steering.py +464 -0
- novacode_cli/bootstrap/vision_router.py +604 -0
- novacode_cli/brand.py +362 -0
- novacode_cli/cli_args.py +718 -0
- novacode_cli/commands/__init__.py +237 -0
- novacode_cli/commands/agents_commands.py +231 -0
- novacode_cli/commands/auth_handler.py +40 -0
- novacode_cli/commands/browser_use_handler.py +75 -0
- novacode_cli/commands/cache_handler.py +32 -0
- novacode_cli/commands/chat_handler.py +1357 -0
- novacode_cli/commands/ci_fix.py +171 -0
- novacode_cli/commands/claude_plan.py +331 -0
- novacode_cli/commands/codex_plan.py +195 -0
- novacode_cli/commands/commands.py +1113 -0
- novacode_cli/commands/create_handler.py +124 -0
- novacode_cli/commands/create_server.py +1887 -0
- novacode_cli/commands/cron_handler.py +119 -0
- novacode_cli/commands/dream_handler.py +203 -0
- novacode_cli/commands/effort_handler.py +65 -0
- novacode_cli/commands/evolution_handler.py +214 -0
- novacode_cli/commands/file_commands.py +381 -0
- novacode_cli/commands/handoff_handler.py +68 -0
- novacode_cli/commands/hooks_handler.py +269 -0
- novacode_cli/commands/init_handler.py +146 -0
- novacode_cli/commands/learning_handler.py +160 -0
- novacode_cli/commands/lite_mode_handler.py +76 -0
- novacode_cli/commands/log_commands.py +363 -0
- novacode_cli/commands/mcp_handler.py +29 -0
- novacode_cli/commands/model_handler.py +29 -0
- novacode_cli/commands/notifications_handler.py +158 -0
- novacode_cli/commands/onboard_handler.py +121 -0
- novacode_cli/commands/plan_handler.py +440 -0
- novacode_cli/commands/planner_usage.py +250 -0
- novacode_cli/commands/plugin_install_handler.py +38 -0
- novacode_cli/commands/plugins_handler.py +35 -0
- novacode_cli/commands/prime_handler.py +455 -0
- novacode_cli/commands/prompt_handler.py +149 -0
- novacode_cli/commands/provider_usage.py +160 -0
- novacode_cli/commands/ralph_events.py +106 -0
- novacode_cli/commands/ralph_handler.py +1293 -0
- novacode_cli/commands/refine_handler.py +213 -0
- novacode_cli/commands/research_handler.py +202 -0
- novacode_cli/commands/rootapp_remote.py +83 -0
- novacode_cli/commands/router_handler.py +36 -0
- novacode_cli/commands/server_commands.py +249 -0
- novacode_cli/commands/session_commands.py +203 -0
- novacode_cli/commands/share_handler.py +450 -0
- novacode_cli/commands/side_commands.py +366 -0
- novacode_cli/commands/skill_invoke.py +420 -0
- novacode_cli/commands/skills_commands.py +48 -0
- novacode_cli/commands/trace_handler.py +322 -0
- novacode_cli/commands/trello_handler.py +226 -0
- novacode_cli/commands/trello_remote.py +177 -0
- novacode_cli/commands/trello_server.py +1214 -0
- novacode_cli/commands/vision_handler.py +127 -0
- novacode_cli/commands/voice_handler.py +447 -0
- novacode_cli/commands/webhook_handler.py +134 -0
- novacode_cli/commands/wiki_commands.py +227 -0
- novacode_cli/compaction.py +753 -0
- novacode_cli/computation_cache.py +159 -0
- novacode_cli/computer/__init__.py +1 -0
- novacode_cli/computer/persistent.py +234 -0
- novacode_cli/computer/preview.py +253 -0
- novacode_cli/computer/runtime.py +474 -0
- novacode_cli/computer/windows_console.py +152 -0
- novacode_cli/computer/windows_qemu.py +211 -0
- novacode_cli/config/browser_mode.py +159 -0
- novacode_cli/config/catalog_models.py +95 -0
- novacode_cli/config/config.py +1388 -0
- novacode_cli/config/credentials.py +540 -0
- novacode_cli/config/google_oauth_auth.py +281 -0
- novacode_cli/config/model_capabilities.py +343 -0
- novacode_cli/config/model_catalog.py +275 -0
- novacode_cli/config/model_create.py +1325 -0
- novacode_cli/config/model_manager.py +695 -0
- novacode_cli/config/nova_config.py +1283 -0
- novacode_cli/config/openai_chatgpt_auth.py +384 -0
- novacode_cli/config/opencode_gateway.py +25 -0
- novacode_cli/config/plan_mode.py +188 -0
- novacode_cli/config/provider_auth.py +438 -0
- novacode_cli/config/provider_catalog.json +5127 -0
- novacode_cli/config/provider_registry.py +38 -0
- novacode_cli/config/provider_transports.py +258 -0
- novacode_cli/config/role_models.py +175 -0
- novacode_cli/context/__init__.py +50 -0
- novacode_cli/context/_analysis.py +799 -0
- novacode_cli/context/_budget.py +116 -0
- novacode_cli/context/_conversation.py +73 -0
- novacode_cli/context/_dynamic.py +264 -0
- novacode_cli/context/_model_config.py +380 -0
- novacode_cli/context/_models_dev.py +99 -0
- novacode_cli/context/archives.py +248 -0
- novacode_cli/context/estimates.py +65 -0
- novacode_cli/context/history.py +17 -0
- novacode_cli/context/knowledge_cache.py +292 -0
- novacode_cli/context/manager.py +101 -0
- novacode_cli/context/observations.py +65 -0
- novacode_cli/context/pressure.py +168 -0
- novacode_cli/context/provider_accounting.py +109 -0
- novacode_cli/context/snapshot.py +493 -0
- novacode_cli/context/strategies.py +279 -0
- novacode_cli/context/task_progress.py +122 -0
- novacode_cli/core/__init__.py +0 -0
- novacode_cli/core/agent_loop.py +1821 -0
- novacode_cli/core/autonomous_loop.py +277 -0
- novacode_cli/core/code_approvals.py +57 -0
- novacode_cli/core/input_preparation.py +276 -0
- novacode_cli/core/reasoning.py +67 -0
- novacode_cli/core/streaming.py +326 -0
- novacode_cli/core/subagent_tasks.py +696 -0
- novacode_cli/core/subagent_tracking.py +235 -0
- novacode_cli/core/tool_results.py +116 -0
- novacode_cli/core/verification_loop.py +272 -0
- novacode_cli/council.py +595 -0
- novacode_cli/council_consensus.py +115 -0
- novacode_cli/council_decision.py +157 -0
- novacode_cli/council_planner.py +1421 -0
- novacode_cli/council_settings.py +208 -0
- novacode_cli/council_web_settings.py +41 -0
- novacode_cli/council_workspace.py +56 -0
- novacode_cli/cowork/__init__.py +10 -0
- novacode_cli/cowork/broker_middleware.py +162 -0
- novacode_cli/cowork/launcher.py +102 -0
- novacode_cli/cowork/policy.py +284 -0
- novacode_cli/cowork/ui.py +826 -0
- novacode_cli/daemons/__init__.py +1 -0
- novacode_cli/daemons/registry.py +342 -0
- novacode_cli/desktop_notifications.py +302 -0
- novacode_cli/doctor.py +251 -0
- novacode_cli/embeddings.py +109 -0
- novacode_cli/entrypoint.py +56 -0
- novacode_cli/errors/__init__.py +19 -0
- novacode_cli/errors/handlers.py +648 -0
- novacode_cli/errors/provider_errors.py +249 -0
- novacode_cli/errors/taxonomy.py +80 -0
- novacode_cli/events.py +179 -0
- novacode_cli/file_ops.py +714 -0
- novacode_cli/git_safety.py +281 -0
- novacode_cli/handoff.py +383 -0
- novacode_cli/headless/__init__.py +18 -0
- novacode_cli/headless/input.py +58 -0
- novacode_cli/headless/output.py +224 -0
- novacode_cli/headless/pipe.py +532 -0
- novacode_cli/headless/runner.py +461 -0
- novacode_cli/hermes/__init__.py +39 -0
- novacode_cli/hermes/activation.py +297 -0
- novacode_cli/hermes/config.py +129 -0
- novacode_cli/hermes/curator.py +199 -0
- novacode_cli/hermes/evolution.py +374 -0
- novacode_cli/hermes/ledger.py +364 -0
- novacode_cli/hermes/memory_tiers.py +969 -0
- novacode_cli/hermes/middleware.py +678 -0
- novacode_cli/hermes/overview.py +174 -0
- novacode_cli/hermes/prompt_evolution.py +503 -0
- novacode_cli/hermes/refine_loop.py +643 -0
- novacode_cli/hermes/refinement_log.py +190 -0
- novacode_cli/hermes/review.py +674 -0
- novacode_cli/hermes/skill_debate.py +135 -0
- novacode_cli/hermes/skill_discovery.py +450 -0
- novacode_cli/hermes/skill_manager.py +335 -0
- novacode_cli/hermes/skill_proposals.py +43 -0
- novacode_cli/hermes/test_gaming.py +119 -0
- novacode_cli/hermes/tracker.py +435 -0
- novacode_cli/hermes/tuner.py +206 -0
- novacode_cli/hermes/verification_memory.py +134 -0
- novacode_cli/hermes/verifier.py +277 -0
- novacode_cli/hitl/__init__.py +22 -0
- novacode_cli/hitl/interrupts.py +493 -0
- novacode_cli/hooks.py +359 -0
- novacode_cli/image_utils.py +517 -0
- novacode_cli/init/__init__.py +7 -0
- novacode_cli/init/events.py +43 -0
- novacode_cli/input_utils.py +526 -0
- novacode_cli/install_commands.py +87 -0
- novacode_cli/integrations/__init__.py +82 -0
- novacode_cli/integrations/daytona.py +122 -0
- novacode_cli/integrations/docker.py +309 -0
- novacode_cli/integrations/host_path.py +111 -0
- novacode_cli/integrations/langsmith.py +151 -0
- novacode_cli/integrations/modal.py +134 -0
- novacode_cli/integrations/runloop.py +130 -0
- novacode_cli/integrations/sandbox_factory.py +1388 -0
- novacode_cli/integrations/sandbox_registry.py +312 -0
- novacode_cli/integrations/workdir_backend.py +589 -0
- novacode_cli/main.py +2078 -0
- novacode_cli/mcp/__init__.py +73 -0
- novacode_cli/mcp/auth.py +189 -0
- novacode_cli/mcp/client.py +571 -0
- novacode_cli/mcp/code_mode.py +5 -0
- novacode_cli/mcp/code_runtime.py +5 -0
- novacode_cli/mcp/commands.py +902 -0
- novacode_cli/mcp/config.py +406 -0
- novacode_cli/mcp/generic.py +128 -0
- novacode_cli/mcp/middleware.py +993 -0
- novacode_cli/mcp/presets.py +367 -0
- novacode_cli/memory/__init__.py +1 -0
- novacode_cli/memory/agent_memory.py +1312 -0
- novacode_cli/memory/builtin_lessons.py +60 -0
- novacode_cli/memory/dream_gates.py +192 -0
- novacode_cli/memory/limits.py +59 -0
- novacode_cli/memory/parsed_cache.py +106 -0
- novacode_cli/memory/recall_stats.py +97 -0
- novacode_cli/memory/semantic.py +116 -0
- novacode_cli/memory/store.py +335 -0
- novacode_cli/memory/wiki.py +274 -0
- novacode_cli/migrate.py +185 -0
- novacode_cli/onboarding.py +649 -0
- novacode_cli/onboarding_check.py +119 -0
- novacode_cli/path_approval.py +297 -0
- novacode_cli/plan_archive.py +148 -0
- novacode_cli/plugins/__init__.py +121 -0
- novacode_cli/plugins/claude_plugins.py +538 -0
- novacode_cli/plugins/cli.py +168 -0
- novacode_cli/plugins/components.py +413 -0
- novacode_cli/plugins/hooks.py +356 -0
- novacode_cli/plugins/loader.py +317 -0
- novacode_cli/plugins/marketplaces.py +199 -0
- novacode_cli/plugins/storage.py +70 -0
- novacode_cli/prime.py +759 -0
- novacode_cli/prime_factory/__init__.py +15 -0
- novacode_cli/prime_factory/container.py +198 -0
- novacode_cli/prime_factory/controller.py +565 -0
- novacode_cli/prime_factory/knowledge.py +261 -0
- novacode_cli/prime_factory/publication.py +218 -0
- novacode_cli/prime_factory/recovery.py +50 -0
- novacode_cli/prime_factory/research.py +370 -0
- novacode_cli/prime_factory/runner.py +608 -0
- novacode_cli/prime_factory/runtime.py +261 -0
- novacode_cli/prime_factory/state.py +201 -0
- novacode_cli/prime_factory/store.py +128 -0
- novacode_cli/prime_factory/worker.py +129 -0
- novacode_cli/prime_factory/workflow.py +899 -0
- novacode_cli/prime_factory/workspace.py +317 -0
- novacode_cli/prime_memory.py +290 -0
- novacode_cli/process_manager.py +585 -0
- novacode_cli/project_onboard.py +537 -0
- novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
- novacode_cli/prompts/__init__.py +168 -0
- novacode_cli/prompts/agent_definitions.jinja +11 -0
- novacode_cli/prompts/agent_generation.jinja +125 -0
- novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
- novacode_cli/prompts/claude_plan_mode.jinja +12 -0
- novacode_cli/prompts/coding_workflow.jinja +18 -0
- novacode_cli/prompts/computer_use.jinja +17 -0
- novacode_cli/prompts/core_agent_system.jinja +81 -0
- novacode_cli/prompts/delegation.jinja +15 -0
- novacode_cli/prompts/desktop_power.jinja +4 -0
- novacode_cli/prompts/file_paths.jinja +15 -0
- novacode_cli/prompts/file_tracker.jinja +23 -0
- novacode_cli/prompts/init_agents_md.jinja +56 -0
- novacode_cli/prompts/init_exploration.jinja +174 -0
- novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
- novacode_cli/prompts/instruction_scope.jinja +5 -0
- novacode_cli/prompts/learnings_extraction.jinja +45 -0
- novacode_cli/prompts/longterm_memory.jinja +54 -0
- novacode_cli/prompts/mcp.jinja +84 -0
- novacode_cli/prompts/memory_consolidation.jinja +40 -0
- novacode_cli/prompts/nova_evolution.jinja +73 -0
- novacode_cli/prompts/nova_review.jinja +158 -0
- novacode_cli/prompts/nova_skill_debate.jinja +36 -0
- novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
- novacode_cli/prompts/nova_verify.jinja +100 -0
- novacode_cli/prompts/onboard_dashboard.jinja +378 -0
- novacode_cli/prompts/plan_agent.jinja +87 -0
- novacode_cli/prompts/plan_scouts.jinja +5 -0
- novacode_cli/prompts/plan_storage.jinja +8 -0
- novacode_cli/prompts/planning.jinja +82 -0
- novacode_cli/prompts/ponytail/LICENSE +21 -0
- novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
- novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
- novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
- novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
- novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
- novacode_cli/prompts/prompt_evolution.jinja +27 -0
- novacode_cli/prompts/ralph_iteration.jinja +75 -0
- novacode_cli/prompts/refine_gate.jinja +31 -0
- novacode_cli/prompts/refine_plan.jinja +64 -0
- novacode_cli/prompts/refine_review.jinja +34 -0
- novacode_cli/prompts/research_delivery.jinja +9 -0
- novacode_cli/prompts/research_swarm.jinja +130 -0
- novacode_cli/prompts/response_style.jinja +5 -0
- novacode_cli/prompts/session_summarization.jinja +86 -0
- novacode_cli/prompts/skill_creation.jinja +85 -0
- novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
- novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
- novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
- novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
- novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
- novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
- novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
- novacode_cli/prompts/summarization.jinja +107 -0
- novacode_cli/prompts/summarization_answers.jinja +23 -0
- novacode_cli/prompts/summarization_questions.jinja +32 -0
- novacode_cli/prompts/vision_caption.jinja +21 -0
- novacode_cli/prompts/voice_summary.jinja +65 -0
- novacode_cli/prompts/wiki_file.jinja +57 -0
- novacode_cli/prompts/wiki_ingest.jinja +87 -0
- novacode_cli/recovery.py +383 -0
- novacode_cli/remote/__init__.py +45 -0
- novacode_cli/remote/approvals.py +282 -0
- novacode_cli/remote/async_task_clients.py +97 -0
- novacode_cli/remote/async_task_watcher.py +240 -0
- novacode_cli/remote/bridge.py +905 -0
- novacode_cli/remote/command_menu.py +26 -0
- novacode_cli/remote/command_ui.py +243 -0
- novacode_cli/remote/config.py +199 -0
- novacode_cli/remote/discord_bridge.py +530 -0
- novacode_cli/remote/discord_format.py +31 -0
- novacode_cli/remote/discord_threads.py +225 -0
- novacode_cli/remote/images.py +97 -0
- novacode_cli/remote/processor.py +601 -0
- novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
- novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
- novacode_cli/remote/rootapp_bot/package.json +18 -0
- novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
- novacode_cli/remote/rootapp_bridge.py +370 -0
- novacode_cli/remote/rootapp_setup.py +89 -0
- novacode_cli/remote/routing.py +103 -0
- novacode_cli/remote/scheduler.py +302 -0
- novacode_cli/remote/session.py +164 -0
- novacode_cli/remote/status.py +411 -0
- novacode_cli/remote/streaming.py +188 -0
- novacode_cli/remote/telegram_bridge.py +1118 -0
- novacode_cli/remote/telegram_format.py +215 -0
- novacode_cli/remote/telegram_hub.py +256 -0
- novacode_cli/remote/voice_notes.py +120 -0
- novacode_cli/remote/webhook_adapters.py +167 -0
- novacode_cli/remote/webhook_server.py +154 -0
- novacode_cli/rewind.py +150 -0
- novacode_cli/rooms/__init__.py +1 -0
- novacode_cli/rooms/bridge.py +86 -0
- novacode_cli/rooms/cli.py +231 -0
- novacode_cli/rooms/nova.py +227 -0
- novacode_cli/rooms/runtime.py +299 -0
- novacode_cli/rooms/store.py +538 -0
- novacode_cli/rooms/workspace.py +94 -0
- novacode_cli/security/__init__.py +29 -0
- novacode_cli/security/approval_context.py +45 -0
- novacode_cli/security/delegated_approval.py +68 -0
- novacode_cli/security/local_http.py +85 -0
- novacode_cli/security/middleware.py +87 -0
- novacode_cli/security/paths.py +35 -0
- novacode_cli/security/policy.py +535 -0
- novacode_cli/security/policy_writer.py +90 -0
- novacode_cli/security/prompt_injection.py +203 -0
- novacode_cli/security/remember.py +81 -0
- novacode_cli/security/rule_synthesis.py +116 -0
- novacode_cli/security/secret_files.py +61 -0
- novacode_cli/security/session_allow.py +114 -0
- novacode_cli/security/unicode_security.py +516 -0
- novacode_cli/security/validator.py +173 -0
- novacode_cli/server/__init__.py +9 -0
- novacode_cli/server/app.py +632 -0
- novacode_cli/server/event_adapter.py +189 -0
- novacode_cli/server/session_manager.py +90 -0
- novacode_cli/server_main.py +64 -0
- novacode_cli/server_runner/dev_server.py +530 -0
- novacode_cli/server_runner/test_runner.py +470 -0
- novacode_cli/session/__init__.py +1 -0
- novacode_cli/session/adapters/__init__.py +240 -0
- novacode_cli/session/adapters/claude_code.py +102 -0
- novacode_cli/session/adapters/codex.py +77 -0
- novacode_cli/session/adapters/harness_native.py +75 -0
- novacode_cli/session/checkpoint_coordinator.py +448 -0
- novacode_cli/session/context_restore.py +25 -0
- novacode_cli/session/history_export.py +228 -0
- novacode_cli/session/history_tree.py +174 -0
- novacode_cli/session/imported_context.py +267 -0
- novacode_cli/session/runtime_identity.py +32 -0
- novacode_cli/session/session_persistence.py +1168 -0
- novacode_cli/session/session_prompt_builder.py +279 -0
- novacode_cli/session/session_restore.py +351 -0
- novacode_cli/session/session_summarization.py +20 -0
- novacode_cli/session/transcript_journal.py +153 -0
- novacode_cli/sessions/__init__.py +11 -0
- novacode_cli/sessions/gateway.py +84 -0
- novacode_cli/sessions/launch.py +51 -0
- novacode_cli/sessions/lease.py +213 -0
- novacode_cli/sessions/messaging.py +255 -0
- novacode_cli/sessions/protocol.py +216 -0
- novacode_cli/sessions/resume.py +31 -0
- novacode_cli/sessions/supervisor.py +498 -0
- novacode_cli/sessions/tasks.py +73 -0
- novacode_cli/sessions/tui_messaging.py +413 -0
- novacode_cli/sessions/worker.py +652 -0
- novacode_cli/sessions/worktree.py +262 -0
- novacode_cli/shell/__init__.py +27 -0
- novacode_cli/shell/jobs.py +475 -0
- novacode_cli/shell/middleware.py +1568 -0
- novacode_cli/shell/os_sandbox.py +205 -0
- novacode_cli/shell/output.py +181 -0
- novacode_cli/shell/patterns.py +275 -0
- novacode_cli/shell/processes.py +87 -0
- novacode_cli/shell/utils.py +236 -0
- novacode_cli/skills/__init__.py +26 -0
- novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
- novacode_cli/skills/curation_middleware.py +69 -0
- novacode_cli/skills/executable.py +161 -0
- novacode_cli/skills/libraries.py +102 -0
- novacode_cli/skills/load.py +248 -0
- novacode_cli/skills/prune.py +215 -0
- novacode_cli/skills/refreshing_middleware.py +896 -0
- novacode_cli/skills/retrieval.py +248 -0
- novacode_cli/skills/runtime.py +118 -0
- novacode_cli/skills/schema.py +254 -0
- novacode_cli/skills/skill_creation.py +1549 -0
- novacode_cli/skills/skill_lock.py +75 -0
- novacode_cli/skills/skill_system_prompt.py +16 -0
- novacode_cli/skills/skills_prefs.py +126 -0
- novacode_cli/skills/tool_resolver.py +31 -0
- novacode_cli/skills/upstream.py +50 -0
- novacode_cli/skills/versioning.py +291 -0
- novacode_cli/states/Session.py +847 -0
- novacode_cli/states/__init__.py +31 -0
- novacode_cli/states/slices/__init__.py +17 -0
- novacode_cli/states/slices/agent_runtime.py +249 -0
- novacode_cli/states/slices/background_tasks.py +21 -0
- novacode_cli/states/slices/notifications.py +74 -0
- novacode_cli/states/slices/remote_bridge.py +56 -0
- novacode_cli/states/slices/ui_settings.py +49 -0
- novacode_cli/states/slices/wiki.py +25 -0
- novacode_cli/teammates/__init__.py +1 -0
- novacode_cli/teammates/controller.py +557 -0
- novacode_cli/teammates/graph.py +104 -0
- novacode_cli/teammates/registry.py +175 -0
- novacode_cli/teammates/service.py +254 -0
- novacode_cli/teammates/worker.py +19 -0
- novacode_cli/teams/__init__.py +1 -0
- novacode_cli/teams/adapters/__init__.py +1 -0
- novacode_cli/teams/adapters/container.py +316 -0
- novacode_cli/teams/adapters/inprocess.py +206 -0
- novacode_cli/teams/adapters/process.py +364 -0
- novacode_cli/teams/bootstrap.py +246 -0
- novacode_cli/teams/container_git.py +67 -0
- novacode_cli/teams/context.py +34 -0
- novacode_cli/teams/coordinator.py +815 -0
- novacode_cli/teams/gateway.py +200 -0
- novacode_cli/teams/host.py +64 -0
- novacode_cli/teams/legacy.py +52 -0
- novacode_cli/teams/middleware.py +176 -0
- novacode_cli/teams/models.py +104 -0
- novacode_cli/teams/output.py +47 -0
- novacode_cli/teams/protocol.py +50 -0
- novacode_cli/teams/recovery.py +80 -0
- novacode_cli/teams/scheduler.py +44 -0
- novacode_cli/teams/store.py +631 -0
- novacode_cli/teams/worker.py +251 -0
- novacode_cli/teams/workspace.py +100 -0
- novacode_cli/token_utils.py +256 -0
- novacode_cli/tools/__init__.py +104 -0
- novacode_cli/tools/_shared.py +97 -0
- novacode_cli/tools/_types.py +24 -0
- novacode_cli/tools/artifact_tools.py +137 -0
- novacode_cli/tools/code_search_tools.py +330 -0
- novacode_cli/tools/computer_tools.py +77 -0
- novacode_cli/tools/cron_tools.py +65 -0
- novacode_cli/tools/daemon_tool.py +290 -0
- novacode_cli/tools/desktop_tools.py +161 -0
- novacode_cli/tools/fetch_tools.py +530 -0
- novacode_cli/tools/http_tools.py +279 -0
- novacode_cli/tools/inspection_tools.py +107 -0
- novacode_cli/tools/job_tools.py +143 -0
- novacode_cli/tools/knowledge_tools.py +427 -0
- novacode_cli/tools/memory_tools.py +461 -0
- novacode_cli/tools/memory_wiki_tools.py +40 -0
- novacode_cli/tools/notebook_tools.py +238 -0
- novacode_cli/tools/oracle_tool.py +83 -0
- novacode_cli/tools/plan_archive_tools.py +80 -0
- novacode_cli/tools/plan_mode_tools.py +315 -0
- novacode_cli/tools/python_kernel_loop.py +165 -0
- novacode_cli/tools/python_kernel_tool.py +222 -0
- novacode_cli/tools/reflection_tools.py +34 -0
- novacode_cli/tools/scraper_tools.py +377 -0
- novacode_cli/tools/skill_tools.py +275 -0
- novacode_cli/tools/speak_tool.py +51 -0
- novacode_cli/tools/store_memory_tools.py +227 -0
- novacode_cli/tools/ui_tools.py +62 -0
- novacode_cli/tools/waiter_tools.py +408 -0
- novacode_cli/tools/web_tools.py +349 -0
- novacode_cli/tools/wiki_tools.py +152 -0
- novacode_cli/tools/worktree_tools.py +219 -0
- novacode_cli/tracking/__init__.py +10 -0
- novacode_cli/tracking/file_tracker.py +826 -0
- novacode_cli/tracking/local_metrics.py +170 -0
- novacode_cli/tracking/loop_guard.py +507 -0
- novacode_cli/tracking/request_metrics.py +60 -0
- novacode_cli/tracking/tracing.py +692 -0
- novacode_cli/tracking/usage_tree.py +677 -0
- novacode_cli/tracking/workspace_anchoring.py +231 -0
- novacode_cli/tui/__init__.py +11 -0
- novacode_cli/tui/animation_rate.py +9 -0
- novacode_cli/tui/animations.py +332 -0
- novacode_cli/tui/app.py +19315 -0
- novacode_cli/tui/auth_display.py +87 -0
- novacode_cli/tui/auth_screens.py +826 -0
- novacode_cli/tui/avatar.py +294 -0
- novacode_cli/tui/avatar_frames.py +21 -0
- novacode_cli/tui/background_tasks.py +35 -0
- novacode_cli/tui/browser_settings_screen.py +70 -0
- novacode_cli/tui/caveman_screen.py +185 -0
- novacode_cli/tui/council_settings_screen.py +182 -0
- novacode_cli/tui/council_view.py +535 -0
- novacode_cli/tui/decision_settings.py +268 -0
- novacode_cli/tui/diff_preview.py +199 -0
- novacode_cli/tui/gc_tuning.py +63 -0
- novacode_cli/tui/handoff_screen.py +206 -0
- novacode_cli/tui/harness.py +244 -0
- novacode_cli/tui/history_tree_screen.py +179 -0
- novacode_cli/tui/home.py +328 -0
- novacode_cli/tui/home_scene.py +94 -0
- novacode_cli/tui/icons.py +81 -0
- novacode_cli/tui/interface_styles.tcss +327 -0
- novacode_cli/tui/matrix3.py +108 -0
- novacode_cli/tui/mcp_tools_screen.py +131 -0
- novacode_cli/tui/motion.py +178 -0
- novacode_cli/tui/output_body.py +136 -0
- novacode_cli/tui/output_buffer.py +59 -0
- novacode_cli/tui/palette.py +334 -0
- novacode_cli/tui/pickers.py +237 -0
- novacode_cli/tui/prime_panel.py +506 -0
- novacode_cli/tui/provider_signin.py +213 -0
- novacode_cli/tui/pty_shell.py +159 -0
- novacode_cli/tui/reference_screens.py +444 -0
- novacode_cli/tui/resume_picker.py +346 -0
- novacode_cli/tui/rooms.py +597 -0
- novacode_cli/tui/screens.py +7088 -0
- novacode_cli/tui/session_import.py +333 -0
- novacode_cli/tui/session_pane.py +242 -0
- novacode_cli/tui/session_tab.py +42 -0
- novacode_cli/tui/session_transcript.py +118 -0
- novacode_cli/tui/shell_output.py +7 -0
- novacode_cli/tui/stall_watch.py +174 -0
- novacode_cli/tui/statusline.py +40 -0
- novacode_cli/tui/statusline_screen.py +91 -0
- novacode_cli/tui/teammate_updates.py +162 -0
- novacode_cli/tui/teammates.py +548 -0
- novacode_cli/tui/terminal.py +289 -0
- novacode_cli/tui/terminal_view.py +235 -0
- novacode_cli/tui/themes.py +118 -0
- novacode_cli/tui/update_notice.py +84 -0
- novacode_cli/tui/voice_downloads.py +113 -0
- novacode_cli/tui/widgets.py +2038 -0
- novacode_cli/tui/windows_input.py +38 -0
- novacode_cli/tui/workspace_approval.py +165 -0
- novacode_cli/ui/council_web.py +113 -0
- novacode_cli/ui/council_workspace.py +72 -0
- novacode_cli/ui/cowork_web.py +41 -0
- novacode_cli/ui/diff_highlight.py +160 -0
- novacode_cli/ui/execution.py +1396 -0
- novacode_cli/ui/exit_summary.py +65 -0
- novacode_cli/ui/hitl_approval.py +196 -0
- novacode_cli/ui/input_preparation.py +2 -0
- novacode_cli/ui/interrupt_handlers.py +215 -0
- novacode_cli/ui/question_prompt.py +151 -0
- novacode_cli/ui/status_phrases.py +157 -0
- novacode_cli/ui/stream_preview.py +66 -0
- novacode_cli/ui/streaming.py +2 -0
- novacode_cli/ui/subagent_tracking.py +2 -0
- novacode_cli/ui/ui_elements.py +1585 -0
- novacode_cli/ui/web_workspace.py +244 -0
- novacode_cli/ui_events.py +299 -0
- novacode_cli/update_ui.py +243 -0
- novacode_cli/updates.py +489 -0
- novacode_cli/utils/backend_patches.py +399 -0
- novacode_cli/utils/backend_paths.py +467 -0
- novacode_cli/utils/lazy_providers.py +51 -0
- novacode_cli/utils/model_info.py +223 -0
- novacode_cli/utils/pdf_extraction.py +183 -0
- novacode_cli/utils/startup.py +29 -0
- novacode_cli/utils/tasks.py +42 -0
- novacode_cli/utils/windows.py +9 -0
- novacode_cli/vixie/hook_adapter.py +127 -0
- novacode_cli/vixie/server.py +375 -0
- novacode_cli/wiki/__init__.py +15 -0
- novacode_cli/wiki/ask.py +56 -0
- novacode_cli/wiki/file.py +71 -0
- novacode_cli/wiki/ingest.py +184 -0
- novacode_cli/wiki/manager.py +261 -0
- novacode_cli-1.2.0.dist-info/METADATA +271 -0
- novacode_cli-1.2.0.dist-info/RECORD +691 -0
- novacode_cli-1.2.0.dist-info/WHEEL +5 -0
- novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
- novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Optimized FilesystemBackend wrapping deepagents.backends.filesystem.
|
|
2
|
+
|
|
3
|
+
Improvements over the upstream :class:`FilesystemBackend`, all delivered as
|
|
4
|
+
method overrides (the deepagents package is never modified — the filesystem
|
|
5
|
+
tools call ``backend.read/edit/glob/grep``, so overriding those backend methods
|
|
6
|
+
improves the tools transparently):
|
|
7
|
+
|
|
8
|
+
1. **grep hang fix** — the upstream Python fallback walks the tree with
|
|
9
|
+
``rglob("*")``, which cannot skip large, uninteresting directories (``.venv``,
|
|
10
|
+
``.git``, ``node_modules``). We override ``_python_search`` to use ``os.walk``
|
|
11
|
+
with in-place ``dirnames`` pruning so those subtrees are never descended into.
|
|
12
|
+
|
|
13
|
+
2. **Regex support** — ``grep()`` gains an opt-in ``use_regex`` flag, fully
|
|
14
|
+
backward-compatible with the literal default.
|
|
15
|
+
|
|
16
|
+
3. **Smart-case grep** — a lower-case pattern matches case-insensitively; a
|
|
17
|
+
pattern with any upper-case letter stays case-sensitive (ripgrep's default).
|
|
18
|
+
|
|
19
|
+
4. **read encoding recovery** — ``read()`` falls back to ``utf-8-sig`` / cp1252 /
|
|
20
|
+
latin-1 when a file isn't utf-8, instead of returning a decode error.
|
|
21
|
+
|
|
22
|
+
5. **edit failure hints** — a failed ``edit()`` gets an actionable hint (closest
|
|
23
|
+
line for a miss, replace_all/disambiguation tip for an ambiguous match).
|
|
24
|
+
|
|
25
|
+
6. **glob prune + ordering** — ``glob()`` drops vendored-dir noise and returns
|
|
26
|
+
newest-first.
|
|
27
|
+
|
|
28
|
+
Async tool calls (``aread``/``aedit``/``aglob``/``agrep``) delegate to these
|
|
29
|
+
sync methods via ``asyncio.to_thread`` in the protocol base, so they are covered
|
|
30
|
+
too without overriding the async variants.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import functools
|
|
36
|
+
import inspect
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import wcmatch.glob as wcglob
|
|
46
|
+
from deepagents.backends.filesystem import FilesystemBackend
|
|
47
|
+
from deepagents.backends.local_shell import LocalShellBackend
|
|
48
|
+
from deepagents.backends.protocol import EditResult, GlobResult, GrepResult, ReadResult, WriteResult
|
|
49
|
+
|
|
50
|
+
from novacode_cli.core.tool_results import current_read_capture
|
|
51
|
+
|
|
52
|
+
__all__ = ["OptimizedFilesystemBackend", "OptimizedLocalShellBackend"]
|
|
53
|
+
|
|
54
|
+
# Wall-clock budget for a single grep, in seconds. Defined locally rather than
|
|
55
|
+
# imported from deepagents.backends.protocol: the constant only exists in
|
|
56
|
+
# deepagents>=0.6.8, but Nova is launched against environments still on 0.6.7
|
|
57
|
+
# (e.g. a miniconda install). The value matches upstream's default.
|
|
58
|
+
_GREP_TIMEOUT = 30
|
|
59
|
+
# deepagents' read error for an offset past the end of the file.
|
|
60
|
+
_PAST_END = re.compile(r"exceeds file length \((\d+) lines\)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@functools.cache
|
|
64
|
+
def _resolve_ripgrep_path() -> str | None:
|
|
65
|
+
r"""Locate the ``rg`` executable, more thoroughly than ``shutil.which``.
|
|
66
|
+
|
|
67
|
+
The upstream resolver only checks ``PATH``. On Windows, package managers like
|
|
68
|
+
winget install ripgrep as a loose archive and never link it onto ``PATH``
|
|
69
|
+
(e.g. ``...\\WinGet\\Packages\\BurntSushi.ripgrep...\\rg.exe``), so the agent
|
|
70
|
+
silently falls back to the slow Python search even though ripgrep is present.
|
|
71
|
+
|
|
72
|
+
Resolution order:
|
|
73
|
+
1. ``NOVA_RIPGREP_PATH`` / ``RIPGREP_PATH`` env override (explicit wins).
|
|
74
|
+
2. ``shutil.which`` (honours ``PATH`` — the fast, normal case).
|
|
75
|
+
3. Known package-manager install locations (winget, VS Code bundle, cargo,
|
|
76
|
+
scoop, chocolatey), globbed for the versioned subdirectories they use.
|
|
77
|
+
|
|
78
|
+
Cached for the process lifetime; ``cache_clear()`` re-probes.
|
|
79
|
+
"""
|
|
80
|
+
for env_var in ("NOVA_RIPGREP_PATH", "RIPGREP_PATH"):
|
|
81
|
+
override = os.environ.get(env_var)
|
|
82
|
+
if override and Path(override).is_file():
|
|
83
|
+
return override
|
|
84
|
+
|
|
85
|
+
for name in ("rg", "rg.exe"):
|
|
86
|
+
found = shutil.which(name)
|
|
87
|
+
if found:
|
|
88
|
+
return found
|
|
89
|
+
|
|
90
|
+
# Glob patterns for package managers that don't touch PATH. Each may contain
|
|
91
|
+
# a version directory, so we glob and take the newest match.
|
|
92
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
93
|
+
userprofile = os.environ.get("USERPROFILE", "")
|
|
94
|
+
programdata = os.environ.get("PROGRAMDATA", "")
|
|
95
|
+
candidates: list[str] = []
|
|
96
|
+
if local:
|
|
97
|
+
candidates += [
|
|
98
|
+
# winget loose-archive install
|
|
99
|
+
rf"{local}\Microsoft\WinGet\Packages\BurntSushi.ripgrep*\**\rg.exe",
|
|
100
|
+
# VS Code bundled ripgrep
|
|
101
|
+
rf"{local}\Programs\Microsoft VS Code\resources\app\node_modules*\**\rg.exe",
|
|
102
|
+
]
|
|
103
|
+
if userprofile:
|
|
104
|
+
candidates += [
|
|
105
|
+
rf"{userprofile}\.cargo\bin\rg.exe",
|
|
106
|
+
rf"{userprofile}\scoop\shims\rg.exe",
|
|
107
|
+
rf"{userprofile}\scoop\apps\ripgrep\**\rg.exe",
|
|
108
|
+
]
|
|
109
|
+
if programdata:
|
|
110
|
+
candidates.append(rf"{programdata}\chocolatey\bin\rg.exe")
|
|
111
|
+
|
|
112
|
+
newest: tuple[float, str] | None = None
|
|
113
|
+
for pattern in candidates:
|
|
114
|
+
for match in _glob_abs(pattern):
|
|
115
|
+
try:
|
|
116
|
+
if not match.is_file():
|
|
117
|
+
continue
|
|
118
|
+
mtime = match.stat().st_mtime
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
if newest is None or mtime > newest[0]:
|
|
122
|
+
newest = (mtime, str(match))
|
|
123
|
+
return newest[1] if newest else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _glob_abs(windows_pattern: str) -> list[Path]:
|
|
127
|
+
r"""Glob an absolute Windows pattern by anchoring at its drive root.
|
|
128
|
+
|
|
129
|
+
``Path.glob`` rejects absolute/drive-anchored patterns, so split off the
|
|
130
|
+
anchor (e.g. ``C:\\``) and glob the remainder against it:
|
|
131
|
+
``C:\\a\\**\\rg.exe`` → ``Path("C:\\").glob("a/**/rg.exe")``.
|
|
132
|
+
"""
|
|
133
|
+
p = Path(windows_pattern)
|
|
134
|
+
anchor = p.anchor
|
|
135
|
+
if not anchor:
|
|
136
|
+
return list(Path().glob(str(p).replace("\\", "/")))
|
|
137
|
+
rel = str(p)[len(anchor) :].replace("\\", "/")
|
|
138
|
+
try:
|
|
139
|
+
return list(Path(anchor).glob(rel))
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Directory names pruned during the Python grep fallback. These are virtually
|
|
145
|
+
# always gitignored and can each hold millions of files; descending into them is
|
|
146
|
+
# what made the fallback hang. An explicit search *into* one of these (e.g.
|
|
147
|
+
# path="/node_modules") still works — only nested occurrences are skipped.
|
|
148
|
+
_SKIP_DIRS = frozenset(
|
|
149
|
+
{
|
|
150
|
+
".venv",
|
|
151
|
+
".env",
|
|
152
|
+
"venv",
|
|
153
|
+
".git",
|
|
154
|
+
".hg",
|
|
155
|
+
".svn",
|
|
156
|
+
"node_modules",
|
|
157
|
+
"__pycache__",
|
|
158
|
+
".pytest_cache",
|
|
159
|
+
".mypy_cache",
|
|
160
|
+
".ruff_cache",
|
|
161
|
+
"dist",
|
|
162
|
+
"build",
|
|
163
|
+
".next",
|
|
164
|
+
".turbo",
|
|
165
|
+
".cargo",
|
|
166
|
+
"target",
|
|
167
|
+
"out",
|
|
168
|
+
".vscode",
|
|
169
|
+
".idea",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# deepagents changed its internal grep contract in 0.7.0, and pyproject allows
|
|
175
|
+
# both (``deepagents>=0.6.8``):
|
|
176
|
+
#
|
|
177
|
+
# 0.6.10: _ripgrep_search(pattern, base, glob) -> dict | None
|
|
178
|
+
# _python_search(pattern, base, glob, *, timeout) -> (dict, error)
|
|
179
|
+
# 0.7.0: _ripgrep_search(pattern, base, glob, max_count) -> (dict | None, truncated)
|
|
180
|
+
# _python_search(..., *, max_count, timeout) -> (dict, truncated, error)
|
|
181
|
+
#
|
|
182
|
+
# The parent's own ``grep()`` unpacks whichever shape ITS version expects, so an
|
|
183
|
+
# override that returns only one shape breaks the other install ("'tuple' object
|
|
184
|
+
# has no attribute 'items'" on 0.6.x). Detect the installed contract once and
|
|
185
|
+
# have the overrides return the matching shape.
|
|
186
|
+
_PARENT_RG_V2 = "max_count" in inspect.signature(FilesystemBackend._ripgrep_search).parameters
|
|
187
|
+
_PARENT_PY_V2 = "max_count" in inspect.signature(FilesystemBackend._python_search).parameters
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cap_results(
|
|
191
|
+
results: dict[str, list[tuple[int, str]]], max_count: int | None
|
|
192
|
+
) -> tuple[dict[str, list[tuple[int, str]]], bool]:
|
|
193
|
+
"""Trim *results* to at most ``max_count`` matches, reporting truncation.
|
|
194
|
+
|
|
195
|
+
Mirrors the upstream grep contract: the caller gets exactly ``max_count``
|
|
196
|
+
matches and ``truncated=True`` only when matches were actually dropped.
|
|
197
|
+
"""
|
|
198
|
+
if max_count is None or max_count < 0:
|
|
199
|
+
return results, False
|
|
200
|
+
kept = 0
|
|
201
|
+
capped: dict[str, list[tuple[int, str]]] = {}
|
|
202
|
+
truncated = False
|
|
203
|
+
for fpath, items in results.items():
|
|
204
|
+
if kept >= max_count:
|
|
205
|
+
truncated = True
|
|
206
|
+
break
|
|
207
|
+
room = max_count - kept
|
|
208
|
+
capped[fpath] = items[:room]
|
|
209
|
+
if len(items) > room:
|
|
210
|
+
truncated = True
|
|
211
|
+
kept += len(capped[fpath])
|
|
212
|
+
return capped, truncated
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _decode_text_bytes(raw: bytes) -> str:
|
|
216
|
+
"""Decode file bytes without depending on the platform's locale codec.
|
|
217
|
+
|
|
218
|
+
``Path.read_text()`` uses the locale's preferred encoding, which is cp1252
|
|
219
|
+
on Windows: a UTF-8 file then fails to decode and the whole file is skipped
|
|
220
|
+
(silently losing matches). Try utf-8 first, then the same ladder the
|
|
221
|
+
``read`` path uses; latin-1 always succeeds, so a search never dies on an
|
|
222
|
+
undecodable byte.
|
|
223
|
+
"""
|
|
224
|
+
for enc in ("utf-8-sig", "cp1252"):
|
|
225
|
+
try:
|
|
226
|
+
return raw.decode(enc)
|
|
227
|
+
except (UnicodeDecodeError, LookupError):
|
|
228
|
+
continue
|
|
229
|
+
return raw.decode("latin-1", errors="replace")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class OptimizedFilesystemBackend(FilesystemBackend):
|
|
233
|
+
"""FilesystemBackend with a non-hanging grep fallback and regex support."""
|
|
234
|
+
|
|
235
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
236
|
+
"""Keep agent-authored skills discoverable in supported skill libraries."""
|
|
237
|
+
# Check only actual installed skill paths, never arbitrary SKILL.md
|
|
238
|
+
# examples or repository source files. The parent still owns the write
|
|
239
|
+
# and its symlink/path protections; validation happens before mutation.
|
|
240
|
+
if Path(file_path).name == "SKILL.md":
|
|
241
|
+
try:
|
|
242
|
+
target = self._resolve_path(file_path)
|
|
243
|
+
library = target.parent.parent
|
|
244
|
+
if library.name == "skills" and library.parent.name in {
|
|
245
|
+
".nova", ".agents", ".claude",
|
|
246
|
+
}:
|
|
247
|
+
from novacode_cli.skills.schema import prepare_skill_content
|
|
248
|
+
|
|
249
|
+
content = prepare_skill_content(content, target.parent.name)
|
|
250
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
251
|
+
return WriteResult(error=f"Cannot save skill '{file_path}': {exc}")
|
|
252
|
+
return super().write(file_path, content)
|
|
253
|
+
|
|
254
|
+
def _to_virtual_path(self, path: str | Path) -> str:
|
|
255
|
+
"""Map a real path to its virtual form, following symlinks/junctions.
|
|
256
|
+
|
|
257
|
+
The parent does ``path.resolve().relative_to(self.cwd)``. When a child is
|
|
258
|
+
a symlink or Windows junction whose *target* lives outside the root, that
|
|
259
|
+
raises ``ValueError`` and the entry is silently dropped from ``ls``/glob —
|
|
260
|
+
so a skill installed as ``~/.claude/skills/<name>`` junctioned to
|
|
261
|
+
``~/.agents/skills/<name>`` never gets discovered (``/<name>`` command
|
|
262
|
+
"isn't a recognized command"). Claude Code follows these links; Nova must
|
|
263
|
+
too.
|
|
264
|
+
|
|
265
|
+
Fix: if the strict resolve escapes the root, fall back to the child's
|
|
266
|
+
*lexical* position — resolve the PARENT (normal chain) and re-attach the
|
|
267
|
+
child's own name. A genuine escape (the parent itself is outside root)
|
|
268
|
+
still raises, preserving the containment guard.
|
|
269
|
+
"""
|
|
270
|
+
p = Path(path)
|
|
271
|
+
try:
|
|
272
|
+
return super()._to_virtual_path(p)
|
|
273
|
+
except ValueError:
|
|
274
|
+
# relative_to raises if the parent is also outside the root → genuine
|
|
275
|
+
# escape, let it propagate (excluded, as before).
|
|
276
|
+
rel = p.parent.resolve().relative_to(self.cwd)
|
|
277
|
+
return "/" + (rel / p.name).as_posix()
|
|
278
|
+
|
|
279
|
+
def _resolve_path(self, key: str) -> Path:
|
|
280
|
+
"""Resolve an inbound path, following symlinks/junctions rooted in-tree.
|
|
281
|
+
|
|
282
|
+
The parent rejects any path whose *resolved* target escapes the root. That
|
|
283
|
+
also rejects a legitimate junction placed inside the root — e.g. a skill
|
|
284
|
+
installed as ``~/.claude/skills/<name>`` that points to
|
|
285
|
+
``~/.agents/skills/<name>`` — so ``read``/``glob``/``download_files`` fail
|
|
286
|
+
for it and ``/<name>`` "isn't a recognized command".
|
|
287
|
+
|
|
288
|
+
Virtual paths already forbid ``..`` and ``~`` (below), so a virtual path
|
|
289
|
+
can never climb out of the root *lexically*; the resolved target can only
|
|
290
|
+
escape by following a symlink/junction that physically lives inside the
|
|
291
|
+
root — which is the intended skill-install layout, and what Claude Code
|
|
292
|
+
itself follows. So we keep the parent's traversal guard and the symlink-
|
|
293
|
+
loop guard, and drop only the resolved-target containment rejection.
|
|
294
|
+
Non-virtual mode is unchanged.
|
|
295
|
+
"""
|
|
296
|
+
if not self.virtual_mode:
|
|
297
|
+
return super()._resolve_path(key)
|
|
298
|
+
from deepagents.backends.filesystem import _raise_if_symlink_loop
|
|
299
|
+
|
|
300
|
+
from novacode_cli.integrations.host_path import host_path_to_virtual
|
|
301
|
+
from novacode_cli.security.paths import virtual_relative_path
|
|
302
|
+
|
|
303
|
+
key = host_path_to_virtual(key, str(self.cwd))
|
|
304
|
+
full = (self.cwd / virtual_relative_path(key)).resolve()
|
|
305
|
+
_raise_if_symlink_loop(full)
|
|
306
|
+
return full
|
|
307
|
+
|
|
308
|
+
def grep(
|
|
309
|
+
self,
|
|
310
|
+
pattern: str,
|
|
311
|
+
path: str | None = None,
|
|
312
|
+
glob: str | None = None,
|
|
313
|
+
*,
|
|
314
|
+
use_regex: bool = False,
|
|
315
|
+
) -> GrepResult:
|
|
316
|
+
"""Search for a pattern in files.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
pattern: Text to search for. Literal by default; a regular expression
|
|
320
|
+
when ``use_regex=True``.
|
|
321
|
+
path: Directory or file to search in. Defaults to the current directory.
|
|
322
|
+
glob: Optional glob to filter which files are searched (e.g. ``"*.py"``).
|
|
323
|
+
use_regex: If ``False`` (default) ``pattern`` is matched literally; if
|
|
324
|
+
``True`` it is compiled as a Python regular expression.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
A ``GrepResult`` with matches or an error.
|
|
328
|
+
"""
|
|
329
|
+
# Literal mode is the parent's exact behaviour — and since the parent
|
|
330
|
+
# calls self._ripgrep_search / self._python_search, it already picks up
|
|
331
|
+
# both of our overrides (hang fix included). No need to duplicate it.
|
|
332
|
+
if not use_regex:
|
|
333
|
+
return super().grep(pattern, path, glob)
|
|
334
|
+
|
|
335
|
+
# Regex mode: same orchestration as the parent, but the pattern is passed
|
|
336
|
+
# through unescaped to both ripgrep (no -F) and the Python fallback.
|
|
337
|
+
try:
|
|
338
|
+
base_full = self._resolve_path(path or ".")
|
|
339
|
+
if not base_full.exists():
|
|
340
|
+
return GrepResult(matches=[])
|
|
341
|
+
except ValueError:
|
|
342
|
+
return GrepResult(matches=[])
|
|
343
|
+
except (OSError, RuntimeError) as e:
|
|
344
|
+
return GrepResult(error=f"Error searching path '{path or '.'}': {e}", matches=[])
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
re.compile(pattern)
|
|
348
|
+
except re.error as e:
|
|
349
|
+
return GrepResult(error=f"Invalid regex pattern: {e}", matches=[])
|
|
350
|
+
|
|
351
|
+
# Call the impls, not the version-adaptive overrides: these always return
|
|
352
|
+
# the rich tuple regardless of which deepagents is installed.
|
|
353
|
+
results, truncated = self._rg_impl(pattern, base_full, glob, use_regex=True)
|
|
354
|
+
partial_error: str | None = None
|
|
355
|
+
if results is None:
|
|
356
|
+
results, truncated, partial_error = self._py_impl(pattern, base_full, glob)
|
|
357
|
+
|
|
358
|
+
matches = [
|
|
359
|
+
{"path": fpath, "line": int(line_num), "text": line_text}
|
|
360
|
+
for fpath, items in results.items()
|
|
361
|
+
for line_num, line_text in items
|
|
362
|
+
]
|
|
363
|
+
# GrepResult gained `truncated` in 0.7; 0.6.x rejects the kwarg.
|
|
364
|
+
if _PARENT_RG_V2:
|
|
365
|
+
return GrepResult(error=partial_error, matches=matches, truncated=truncated)
|
|
366
|
+
return GrepResult(error=partial_error, matches=matches)
|
|
367
|
+
|
|
368
|
+
def _ripgrep_search(
|
|
369
|
+
self,
|
|
370
|
+
pattern: str,
|
|
371
|
+
base_full: Path,
|
|
372
|
+
include_glob: str | None,
|
|
373
|
+
max_count: int | None = None,
|
|
374
|
+
*,
|
|
375
|
+
use_regex: bool = False,
|
|
376
|
+
):
|
|
377
|
+
"""Version-adaptive override the parent ``grep()`` calls.
|
|
378
|
+
|
|
379
|
+
Returns ``(results, truncated)`` on deepagents >= 0.7 and a bare
|
|
380
|
+
``results`` dict on 0.6.x — matching whatever the installed parent
|
|
381
|
+
unpacks. Nova's own code should call :meth:`_rg_impl` instead, which
|
|
382
|
+
always returns the richer tuple.
|
|
383
|
+
"""
|
|
384
|
+
out = self._rg_impl(pattern, base_full, include_glob, max_count, use_regex=use_regex)
|
|
385
|
+
return out if _PARENT_RG_V2 else out[0]
|
|
386
|
+
|
|
387
|
+
def _rg_impl( # noqa: PLR0912, PLR0915
|
|
388
|
+
self,
|
|
389
|
+
pattern: str,
|
|
390
|
+
base_full: Path,
|
|
391
|
+
include_glob: str | None,
|
|
392
|
+
max_count: int | None = None,
|
|
393
|
+
*,
|
|
394
|
+
use_regex: bool = False,
|
|
395
|
+
) -> tuple[dict[str, list[tuple[int, str]]] | None, bool]:
|
|
396
|
+
"""Search with ripgrep.
|
|
397
|
+
|
|
398
|
+
Identical to the upstream implementation except that the fixed-string
|
|
399
|
+
flag (``-F``) is omitted when ``use_regex`` is set, so the pattern is
|
|
400
|
+
interpreted as a regular expression. The parent's literal ``grep()`` call
|
|
401
|
+
passes no ``use_regex`` and therefore keeps ``-F``.
|
|
402
|
+
|
|
403
|
+
Returns ``(results, truncated)`` — matching the upstream contract, which
|
|
404
|
+
the parent ``grep()`` unpacks. ``results`` is ``None`` to signal "fall
|
|
405
|
+
back to the Python search" (ripgrep missing, timed out, or errored);
|
|
406
|
+
``truncated`` is True when ``max_count`` capped the result set.
|
|
407
|
+
"""
|
|
408
|
+
from novacode_cli.backends.native_search import ugrep_search
|
|
409
|
+
|
|
410
|
+
native = ugrep_search(
|
|
411
|
+
pattern, base_full, include_glob, use_regex=use_regex,
|
|
412
|
+
skip_dirs=_SKIP_DIRS, timeout=_GREP_TIMEOUT,
|
|
413
|
+
)
|
|
414
|
+
if native is not None:
|
|
415
|
+
if self.virtual_mode:
|
|
416
|
+
virtual = {}
|
|
417
|
+
for p, lines in native.items():
|
|
418
|
+
try:
|
|
419
|
+
virtual["/" + Path(p).relative_to(self.cwd).as_posix()] = lines
|
|
420
|
+
except ValueError:
|
|
421
|
+
continue
|
|
422
|
+
native = virtual
|
|
423
|
+
return _cap_results(native, max_count)
|
|
424
|
+
rg_path = _resolve_ripgrep_path()
|
|
425
|
+
if rg_path is None:
|
|
426
|
+
return None, False
|
|
427
|
+
|
|
428
|
+
# --smart-case: a lower-case pattern matches case-insensitively, but a
|
|
429
|
+
# pattern containing an upper-case letter stays case-sensitive. Matches
|
|
430
|
+
# ripgrep's CLI default and the Python fallback below, so literal grep is
|
|
431
|
+
# forgiving for the common lower-case query without losing precision.
|
|
432
|
+
cmd = [rg_path, "--json", "--smart-case"]
|
|
433
|
+
if not use_regex:
|
|
434
|
+
cmd.append("-F") # fixed-string (literal) mode
|
|
435
|
+
if include_glob:
|
|
436
|
+
cmd.extend(["--glob", include_glob])
|
|
437
|
+
|
|
438
|
+
rg_cwd: str | None = None
|
|
439
|
+
if base_full.is_dir():
|
|
440
|
+
cmd.extend(["--", pattern, "."])
|
|
441
|
+
rg_cwd = str(base_full)
|
|
442
|
+
else:
|
|
443
|
+
cmd.extend(["--", pattern, str(base_full)])
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
proc = subprocess.run( # noqa: S603
|
|
447
|
+
cmd,
|
|
448
|
+
capture_output=True,
|
|
449
|
+
# ripgrep emits UTF-8. Without an explicit encoding Python decodes
|
|
450
|
+
# with the locale codec (cp1252 on Windows), and one non-ASCII
|
|
451
|
+
# character in a matched line raises UnicodeDecodeError inside the
|
|
452
|
+
# reader thread: the search then dies, or silently returns nothing.
|
|
453
|
+
text=True,
|
|
454
|
+
encoding="utf-8",
|
|
455
|
+
errors="replace",
|
|
456
|
+
timeout=_GREP_TIMEOUT,
|
|
457
|
+
check=False,
|
|
458
|
+
cwd=rg_cwd,
|
|
459
|
+
)
|
|
460
|
+
except subprocess.TimeoutExpired:
|
|
461
|
+
return None, False
|
|
462
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError):
|
|
463
|
+
# rg resolved at cache time but failed to exec — re-probe next call.
|
|
464
|
+
_resolve_ripgrep_path.cache_clear()
|
|
465
|
+
return None, False
|
|
466
|
+
|
|
467
|
+
# 0 = match, 1 = no match (both fine); 2+ = hard error → fall back.
|
|
468
|
+
if proc.returncode not in (0, 1):
|
|
469
|
+
return None, False
|
|
470
|
+
|
|
471
|
+
results: dict[str, list[tuple[int, str]]] = {}
|
|
472
|
+
base_resolved = base_full.resolve()
|
|
473
|
+
# On Windows, proc.stdout can be None even with a 0/1 returncode.
|
|
474
|
+
# Treat that as "no matches" rather than crashing on splitlines().
|
|
475
|
+
if not proc.stdout:
|
|
476
|
+
return results, False
|
|
477
|
+
for line in proc.stdout.splitlines():
|
|
478
|
+
try:
|
|
479
|
+
data = json.loads(line)
|
|
480
|
+
except json.JSONDecodeError:
|
|
481
|
+
continue
|
|
482
|
+
if data.get("type") != "match":
|
|
483
|
+
continue
|
|
484
|
+
pdata = data.get("data", {})
|
|
485
|
+
ftext = pdata.get("path", {}).get("text")
|
|
486
|
+
if not ftext:
|
|
487
|
+
continue
|
|
488
|
+
raw = Path(ftext)
|
|
489
|
+
p = raw if raw.is_absolute() else (base_full / raw)
|
|
490
|
+
# Containment guard: drop any result that resolves outside the root.
|
|
491
|
+
try:
|
|
492
|
+
resolved = p.resolve()
|
|
493
|
+
resolved.relative_to(base_resolved)
|
|
494
|
+
except (ValueError, OSError):
|
|
495
|
+
continue
|
|
496
|
+
if self.virtual_mode:
|
|
497
|
+
try:
|
|
498
|
+
# Already canonicalized for containment above. Resolving it
|
|
499
|
+
# again performs another Windows filesystem round trip per
|
|
500
|
+
# match; derive the identical virtual path from this result.
|
|
501
|
+
virt = "/" + resolved.relative_to(self.cwd).as_posix()
|
|
502
|
+
except (ValueError, OSError, RuntimeError):
|
|
503
|
+
continue
|
|
504
|
+
else:
|
|
505
|
+
virt = str(p)
|
|
506
|
+
ln = pdata.get("line_number")
|
|
507
|
+
if ln is None:
|
|
508
|
+
continue
|
|
509
|
+
lt = pdata.get("lines", {}).get("text", "").rstrip("\n")
|
|
510
|
+
results.setdefault(virt, []).append((int(ln), lt))
|
|
511
|
+
|
|
512
|
+
# ponytail: cap applied after collection, not by killing rg mid-stream as
|
|
513
|
+
# upstream does. Same visible contract (exactly max_count, flagged
|
|
514
|
+
# truncated); only the peak-memory bound is looser. Stream it if a huge
|
|
515
|
+
# match set ever actually hurts.
|
|
516
|
+
return _cap_results(results, max_count)
|
|
517
|
+
|
|
518
|
+
def _python_search(
|
|
519
|
+
self,
|
|
520
|
+
pattern: str,
|
|
521
|
+
base_full: Path,
|
|
522
|
+
include_glob: str | None,
|
|
523
|
+
*,
|
|
524
|
+
max_count: int | None = None,
|
|
525
|
+
timeout: int = _GREP_TIMEOUT,
|
|
526
|
+
):
|
|
527
|
+
"""Version-adaptive override the parent ``grep()`` calls.
|
|
528
|
+
|
|
529
|
+
``(results, truncated, error)`` on deepagents >= 0.7, ``(results, error)``
|
|
530
|
+
on 0.6.x. Nova's own code should call :meth:`_py_impl`.
|
|
531
|
+
"""
|
|
532
|
+
results, truncated, error = self._py_impl(
|
|
533
|
+
re.escape(pattern), base_full, include_glob, max_count=max_count, timeout=timeout
|
|
534
|
+
)
|
|
535
|
+
return (results, truncated, error) if _PARENT_PY_V2 else (results, error)
|
|
536
|
+
|
|
537
|
+
def _py_impl( # noqa: PLR0912
|
|
538
|
+
self,
|
|
539
|
+
pattern: str,
|
|
540
|
+
base_full: Path,
|
|
541
|
+
include_glob: str | None,
|
|
542
|
+
*,
|
|
543
|
+
max_count: int | None = None,
|
|
544
|
+
timeout: int = _GREP_TIMEOUT,
|
|
545
|
+
) -> tuple[dict[str, list[tuple[int, str]]], bool, str | None]:
|
|
546
|
+
"""Python fallback that prunes large directories to avoid hanging.
|
|
547
|
+
|
|
548
|
+
Drop-in replacement for the upstream ``_python_search`` (same signature,
|
|
549
|
+
so the parent ``grep()`` calls this automatically). The only behavioural
|
|
550
|
+
difference is that subtrees in :data:`_SKIP_DIRS` are never descended
|
|
551
|
+
into, which is what kept the upstream version from completing on repos
|
|
552
|
+
with a vendored ``.venv`` / ``node_modules``.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
pattern: Regex pattern. Callers pass ``re.escape(...)`` for a literal
|
|
556
|
+
search (the parent does this) or a raw regex for regex mode.
|
|
557
|
+
base_full: Resolved base path to search.
|
|
558
|
+
include_glob: Optional glob to filter files.
|
|
559
|
+
max_count: Optional cap on total matches collected.
|
|
560
|
+
timeout: Wall-clock budget in seconds.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
``(results, truncated, partial_error)`` — the upstream contract the
|
|
564
|
+
parent ``grep()`` unpacks. ``truncated`` is True when ``max_count``
|
|
565
|
+
dropped matches; ``partial_error`` is set when the walk was cut short
|
|
566
|
+
by the timeout or aborted mid-iteration.
|
|
567
|
+
"""
|
|
568
|
+
deadline = time.monotonic() + timeout
|
|
569
|
+
# Smart-case to mirror the ripgrep path: case-insensitive unless the
|
|
570
|
+
# pattern carries an upper-case letter. `pattern` is already re.escape'd
|
|
571
|
+
# for literal searches, which never adds upper-case, so the check is safe.
|
|
572
|
+
smart_flags = re.IGNORECASE if pattern == pattern.lower() else 0
|
|
573
|
+
regex = re.compile(pattern, smart_flags)
|
|
574
|
+
results: dict[str, list[tuple[int, str]]] = {}
|
|
575
|
+
root = base_full if base_full.is_dir() else base_full.parent
|
|
576
|
+
|
|
577
|
+
def _timed_out() -> str:
|
|
578
|
+
return (
|
|
579
|
+
f"Grep of '{base_full}' timed out after {timeout}s with "
|
|
580
|
+
f"{len(results)} matching file(s); try a more specific pattern "
|
|
581
|
+
f"or a narrower path."
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Collect one past the cap so truncation can be reported exactly (a run
|
|
585
|
+
# that lands on max_count with nothing left is complete, not truncated).
|
|
586
|
+
probe = None if max_count is None else max_count + 1
|
|
587
|
+
matched = 0
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
walk = (
|
|
591
|
+
[(str(root), [], [base_full.name])] if base_full.is_file()
|
|
592
|
+
else os.walk(str(root))
|
|
593
|
+
)
|
|
594
|
+
for dirpath, dirnames, filenames in walk:
|
|
595
|
+
if probe is not None and matched >= probe:
|
|
596
|
+
break
|
|
597
|
+
if time.monotonic() > deadline:
|
|
598
|
+
capped, trunc = _cap_results(results, max_count)
|
|
599
|
+
return capped, trunc, _timed_out()
|
|
600
|
+
|
|
601
|
+
# Prune in place so os.walk never descends into skipped subtrees.
|
|
602
|
+
dirnames[:] = [
|
|
603
|
+
d for d in dirnames if d not in _SKIP_DIRS and not d.endswith(".egg-info")
|
|
604
|
+
]
|
|
605
|
+
|
|
606
|
+
for filename in filenames:
|
|
607
|
+
if probe is not None and matched >= probe:
|
|
608
|
+
break
|
|
609
|
+
if time.monotonic() > deadline:
|
|
610
|
+
capped, trunc = _cap_results(results, max_count)
|
|
611
|
+
return capped, trunc, _timed_out()
|
|
612
|
+
|
|
613
|
+
fp = Path(dirpath) / filename
|
|
614
|
+
|
|
615
|
+
if include_glob:
|
|
616
|
+
rel_path = fp.relative_to(root).as_posix()
|
|
617
|
+
if not wcglob.globmatch(
|
|
618
|
+
rel_path, include_glob,
|
|
619
|
+
flags=wcglob.BRACE | wcglob.GLOBSTAR | wcglob.MATCHBASE,
|
|
620
|
+
):
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
if fp.stat().st_size > self.max_file_size_bytes:
|
|
625
|
+
continue
|
|
626
|
+
except (OSError, RuntimeError):
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
content = _decode_text_bytes(fp.read_bytes())
|
|
631
|
+
except (PermissionError, OSError, RuntimeError):
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
if "\x00" in content:
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
638
|
+
if probe is not None and matched >= probe:
|
|
639
|
+
break
|
|
640
|
+
if not regex.search(line):
|
|
641
|
+
continue
|
|
642
|
+
if self.virtual_mode:
|
|
643
|
+
try:
|
|
644
|
+
virt_path = self._to_virtual_path(fp)
|
|
645
|
+
except (ValueError, OSError, RuntimeError):
|
|
646
|
+
continue
|
|
647
|
+
else:
|
|
648
|
+
virt_path = str(fp)
|
|
649
|
+
results.setdefault(virt_path, []).append((line_num, line))
|
|
650
|
+
matched += 1
|
|
651
|
+
|
|
652
|
+
except (OSError, RuntimeError) as e:
|
|
653
|
+
msg = f"Grep of '{base_full}' aborted after {len(results)} matching file(s): {e}"
|
|
654
|
+
capped, trunc = _cap_results(results, max_count)
|
|
655
|
+
return capped, trunc, msg
|
|
656
|
+
|
|
657
|
+
capped, trunc = _cap_results(results, max_count)
|
|
658
|
+
return capped, trunc, None
|
|
659
|
+
|
|
660
|
+
# -- read: recover non-utf-8 files ---------------------------------------
|
|
661
|
+
|
|
662
|
+
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
|
663
|
+
"""Read a file, recovering from a utf-8 decode failure.
|
|
664
|
+
|
|
665
|
+
Upstream opens text files as utf-8 only, so a cp1252 / latin-1 / UTF-16
|
|
666
|
+
file returns a decode error and reads *nothing*. On that error we re-read
|
|
667
|
+
the bytes through a short encoding ladder (``latin-1`` always succeeds),
|
|
668
|
+
returning the same windowed ``ReadResult`` the tool expects.
|
|
669
|
+
"""
|
|
670
|
+
result = super().read(file_path, offset, limit)
|
|
671
|
+
if result.error and "codec can't decode" in result.error:
|
|
672
|
+
recovered = self._read_fallback_encoding(file_path, offset, limit)
|
|
673
|
+
if recovered is not None:
|
|
674
|
+
result = recovered
|
|
675
|
+
if result.error and (past_end := _PAST_END.search(result.error)):
|
|
676
|
+
# Say what to do next: a bare "exceeds file length" costs a turn guessing.
|
|
677
|
+
total = int(past_end.group(1))
|
|
678
|
+
last_page = max(0, total - limit)
|
|
679
|
+
result = ReadResult(
|
|
680
|
+
error=(
|
|
681
|
+
f"{result.error}. The file ends at line {total}; read the last page "
|
|
682
|
+
f"with offset={last_page}, or start again with offset=0."
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
capture = current_read_capture.get()
|
|
686
|
+
if capture is not None:
|
|
687
|
+
capture.record(result)
|
|
688
|
+
return result
|
|
689
|
+
|
|
690
|
+
def _read_fallback_encoding(
|
|
691
|
+
self, file_path: str, offset: int, limit: int
|
|
692
|
+
) -> ReadResult | None:
|
|
693
|
+
"""Re-read ``file_path`` trying non-utf-8 encodings; ``None`` if all fail."""
|
|
694
|
+
try:
|
|
695
|
+
raw = self._resolve_path(file_path).read_bytes()
|
|
696
|
+
except (OSError, RuntimeError, ValueError):
|
|
697
|
+
return None
|
|
698
|
+
for enc in ("utf-8-sig", "cp1252", "latin-1"):
|
|
699
|
+
try:
|
|
700
|
+
content = raw.decode(enc)
|
|
701
|
+
except (UnicodeDecodeError, LookupError):
|
|
702
|
+
continue
|
|
703
|
+
lines = content.splitlines(keepends=True)
|
|
704
|
+
if lines and offset >= len(lines):
|
|
705
|
+
return ReadResult(
|
|
706
|
+
error=f"Line offset {offset} exceeds file length ({len(lines)} lines)"
|
|
707
|
+
)
|
|
708
|
+
end = min(offset + limit, len(lines))
|
|
709
|
+
windowed = "".join(lines[offset:end])
|
|
710
|
+
return ReadResult(file_data={"content": windowed, "encoding": "utf-8"})
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
# -- edit: actionable failure feedback -----------------------------------
|
|
714
|
+
|
|
715
|
+
def edit(
|
|
716
|
+
self,
|
|
717
|
+
file_path: str,
|
|
718
|
+
old_string: str,
|
|
719
|
+
new_string: str,
|
|
720
|
+
replace_all: bool = False, # noqa: FBT001, FBT002
|
|
721
|
+
) -> EditResult:
|
|
722
|
+
"""Edit a file, adding a recovery hint when the match fails.
|
|
723
|
+
|
|
724
|
+
The upstream error ("String not found" / "appears N times") tells the
|
|
725
|
+
model *that* it failed but not how to fix it. We append a hint — the
|
|
726
|
+
closest line for a miss, or a replace_all/disambiguation tip for an
|
|
727
|
+
ambiguous match — so the next attempt usually lands.
|
|
728
|
+
"""
|
|
729
|
+
result = super().edit(file_path, old_string, new_string, replace_all)
|
|
730
|
+
if not result.error:
|
|
731
|
+
return result
|
|
732
|
+
hint = self._edit_failure_hint(file_path, old_string, result.error)
|
|
733
|
+
if hint:
|
|
734
|
+
return EditResult(error=f"{result.error}\n\n{hint}")
|
|
735
|
+
return result
|
|
736
|
+
|
|
737
|
+
def _edit_failure_hint(self, file_path: str, old_string: str, error: str) -> str | None:
|
|
738
|
+
"""Build a one-line recovery hint for a failed edit (``None`` if none)."""
|
|
739
|
+
low = error.lower()
|
|
740
|
+
if "appears" in low and "times" in low:
|
|
741
|
+
return (
|
|
742
|
+
"Hint: pass replace_all=True to change every occurrence, or extend "
|
|
743
|
+
"old_string with surrounding lines so it matches exactly one place."
|
|
744
|
+
)
|
|
745
|
+
if "not found" not in low:
|
|
746
|
+
return None
|
|
747
|
+
try:
|
|
748
|
+
content = self._resolve_path(file_path).read_text(encoding="utf-8", errors="replace")
|
|
749
|
+
except (OSError, RuntimeError, ValueError):
|
|
750
|
+
return None
|
|
751
|
+
first = next((ln for ln in old_string.splitlines() if ln.strip()), "")
|
|
752
|
+
needle = re.sub(r"\s+", " ", first).strip()
|
|
753
|
+
if not needle:
|
|
754
|
+
return None
|
|
755
|
+
# Whitespace-insensitive locate: indentation / internal-spacing drift is
|
|
756
|
+
# the most common reason an otherwise-correct old_string doesn't match.
|
|
757
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
758
|
+
norm = re.sub(r"\s+", " ", line).strip()
|
|
759
|
+
if norm and needle in norm:
|
|
760
|
+
return (
|
|
761
|
+
f"Hint: no exact match, but line {i} is close:\n {i}: {line}\n"
|
|
762
|
+
"old_string must match byte-for-byte (indentation, quotes, trailing "
|
|
763
|
+
f"spaces). Re-read around line {i} and copy the exact text."
|
|
764
|
+
)
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
# -- glob: prune noise during the walk + newest-first ---------------------
|
|
768
|
+
|
|
769
|
+
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
|
|
770
|
+
"""Glob with vendored dirs pruned *during* the walk, newest-first.
|
|
771
|
+
|
|
772
|
+
Upstream calls ``search_path.rglob(pattern)``, which descends into every
|
|
773
|
+
subtree and ``stat()``s every hit before anything can be filtered. On a
|
|
774
|
+
repo with a vendored ``.venv`` / ``node_modules`` that is tens of
|
|
775
|
+
thousands of files (26k here vs 1.8k real ones), which blows the 20s
|
|
776
|
+
``GLOB_TIMEOUT`` in deepagents' glob tool.
|
|
777
|
+
|
|
778
|
+
Filtering afterwards, as this method used to, still paid the full cost.
|
|
779
|
+
We walk with ``os.walk`` and prune :data:`_SKIP_DIRS` from ``dirnames``
|
|
780
|
+
in place, so those subtrees are never descended into — the same fix
|
|
781
|
+
:meth:`_py_impl` already applies to grep. Results are ordered by mtime so
|
|
782
|
+
the files the user is likely working on come first.
|
|
783
|
+
"""
|
|
784
|
+
if self.virtual_mode and ".." in Path(pattern).parts:
|
|
785
|
+
msg = "Path traversal not allowed in glob pattern"
|
|
786
|
+
raise ValueError(msg)
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
search_path = self.cwd if path in (None, "/") else self._resolve_path(path)
|
|
790
|
+
if not search_path.exists() or not search_path.is_dir():
|
|
791
|
+
return GlobResult(matches=[])
|
|
792
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
793
|
+
return GlobResult(error=f"Error globbing path '{path}': {e}", matches=[])
|
|
794
|
+
|
|
795
|
+
# rglob(p) matches p at any depth; wcglob needs that spelled out.
|
|
796
|
+
match_pat = pattern.lstrip("/")
|
|
797
|
+
if not match_pat.startswith("**/"):
|
|
798
|
+
match_pat = "**/" + match_pat
|
|
799
|
+
flags = wcglob.BRACE | wcglob.GLOBSTAR
|
|
800
|
+
|
|
801
|
+
matches: list[dict] = []
|
|
802
|
+
try:
|
|
803
|
+
from novacode_cli.backends.native_search import bfs_files
|
|
804
|
+
|
|
805
|
+
native_files = bfs_files(search_path, _SKIP_DIRS)
|
|
806
|
+
walk = (
|
|
807
|
+
[(str(p.parent), [], [p.name]) for p in native_files]
|
|
808
|
+
if native_files is not None else os.walk(str(search_path))
|
|
809
|
+
)
|
|
810
|
+
for dirpath, dirnames, filenames in walk:
|
|
811
|
+
dirnames[:] = [
|
|
812
|
+
d for d in dirnames if d not in _SKIP_DIRS and not d.endswith(".egg-info")
|
|
813
|
+
]
|
|
814
|
+
for name in filenames:
|
|
815
|
+
full = Path(dirpath) / name
|
|
816
|
+
try:
|
|
817
|
+
rel = full.relative_to(search_path).as_posix()
|
|
818
|
+
except ValueError:
|
|
819
|
+
continue
|
|
820
|
+
if not wcglob.globmatch(rel, match_pat, flags=flags):
|
|
821
|
+
continue
|
|
822
|
+
if self.virtual_mode:
|
|
823
|
+
try:
|
|
824
|
+
out_path = self._to_virtual_path(full)
|
|
825
|
+
except (OSError, RuntimeError, ValueError):
|
|
826
|
+
continue
|
|
827
|
+
else:
|
|
828
|
+
out_path = str(full)
|
|
829
|
+
try:
|
|
830
|
+
st = full.stat()
|
|
831
|
+
matches.append(
|
|
832
|
+
{
|
|
833
|
+
"path": out_path,
|
|
834
|
+
"is_dir": False,
|
|
835
|
+
"size": int(st.st_size),
|
|
836
|
+
"_mtime": st.st_mtime,
|
|
837
|
+
}
|
|
838
|
+
)
|
|
839
|
+
except OSError:
|
|
840
|
+
matches.append({"path": out_path, "is_dir": False, "_mtime": 0.0})
|
|
841
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
842
|
+
return GlobResult(
|
|
843
|
+
error=f"Glob of '{path}' aborted partway: {e}",
|
|
844
|
+
matches=[{k: v for k, v in m.items() if k != "_mtime"} for m in matches],
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
matches.sort(key=lambda m: m["_mtime"], reverse=True)
|
|
848
|
+
for m in matches:
|
|
849
|
+
del m["_mtime"]
|
|
850
|
+
return GlobResult(matches=matches)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
class ConversationHistoryBackend(FilesystemBackend):
|
|
854
|
+
"""Write-only store for conversation history evicted by summarization.
|
|
855
|
+
|
|
856
|
+
The summarization middleware offloads replaced history here and tells the
|
|
857
|
+
model it can "recover the full text by reading the offloaded file". Acting on
|
|
858
|
+
that advice re-inhales the exact context eviction just cleared, which pushes
|
|
859
|
+
usage back over the summarization threshold and summarizes again — the
|
|
860
|
+
repeating "SESSION INTENT / task not recoverable" loop.
|
|
861
|
+
|
|
862
|
+
Writes still land on disk, so the transcript stays available to the user and
|
|
863
|
+
to `/resume`; only the agent-facing *read* is refused, which is what breaks
|
|
864
|
+
the cycle. Deterministic on purpose: a prompt-level "don't re-read this" can
|
|
865
|
+
be ignored by the model, a refused read cannot.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
|
869
|
+
"""Refuse the read with a short instruction instead of the transcript."""
|
|
870
|
+
return ReadResult(
|
|
871
|
+
error=(
|
|
872
|
+
"This file holds earlier conversation history that was already "
|
|
873
|
+
"summarized out of the active context. Re-reading it would undo "
|
|
874
|
+
"that compaction and re-trigger summarization, so it is not "
|
|
875
|
+
"readable. Continue from the summary you have; if the goal is "
|
|
876
|
+
"unclear, ask the user rather than trying to reconstruct it."
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
class OptimizedLocalShellBackend(LocalShellBackend, OptimizedFilesystemBackend):
|
|
882
|
+
"""A ``LocalShellBackend`` that uses Nova's guarded, non-hanging grep.
|
|
883
|
+
|
|
884
|
+
Nova's local-mode composite default backend must support the ``execute``
|
|
885
|
+
tool (so subagents can run commands), which is why deepagents'
|
|
886
|
+
:class:`LocalShellBackend` is used. But ``LocalShellBackend`` subclasses the
|
|
887
|
+
*base* deepagents ``FilesystemBackend``, whose ``_ripgrep_search`` lacks the
|
|
888
|
+
Windows None-stdout guard and whose Python fallback walks ``.venv`` /
|
|
889
|
+
``node_modules`` (the hang this package fixes). The MRO here —
|
|
890
|
+
``LocalShellBackend`` → :class:`OptimizedFilesystemBackend` → ``FilesystemBackend``
|
|
891
|
+
— keeps ``execute`` from the former while resolving ``grep`` /
|
|
892
|
+
``_ripgrep_search`` to the optimized, guarded versions. No new behaviour of
|
|
893
|
+
its own.
|
|
894
|
+
"""
|