polyrob 0.5.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.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- webview/webgate.py +196 -0
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skill Manager - Loads and matches skills for each session.
|
|
3
|
+
|
|
4
|
+
Skills are context-aware prompt extensions that get loaded into a session's
|
|
5
|
+
system prompt based on:
|
|
6
|
+
1. Which tools are loaded for the session (tool_ids)
|
|
7
|
+
2. Keywords in the task description
|
|
8
|
+
3. Available actions
|
|
9
|
+
|
|
10
|
+
SYSTEM (builtin) skills are stored in the shipped package tree:
|
|
11
|
+
data/prompts/skills/ (see skill_store.builtin_scope()). Per-tenant
|
|
12
|
+
user_<uid> skills are stored under DATA-HOME (skill_store.skills_data_home(),
|
|
13
|
+
Task 8) so they survive a `polyrob update` code-swap, not under this package
|
|
14
|
+
tree — see SkillManager._user_dirs_root().
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Dict, Optional, Set, Any, Tuple
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from agents.task.agent.skill_frontmatter import parse_frontmatter
|
|
25
|
+
from agents.task.agent import skill_store
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Validation constants
|
|
30
|
+
# Task 7: split the single flat char cap into two thresholds. The old
|
|
31
|
+
# MAX_SKILL_CONTENT_CHARS=12000 was a HARD reject in validate_skill_content (the
|
|
32
|
+
# gate used by SkillWriterMixin.create_skill and by import/validation paths),
|
|
33
|
+
# which silently rejected spec-valid agentskills.io skills well within the
|
|
34
|
+
# ~5000-token injected-body recommendation - e.g. the real anthropics/skills
|
|
35
|
+
# `docx` skill (20084 chars) and `skill-creator` (33168 chars) both bust the old
|
|
36
|
+
# 12000 cap, so the import path could not ingest most real-world skills.
|
|
37
|
+
MAX_SKILL_FILE_CHARS = 40000 # on-disk ceiling (DoS guard) - was implicitly 12000
|
|
38
|
+
MAX_SKILL_INJECT_CHARS = 20000 # ~5000 tok recommended injected-body size (agentskills.io)
|
|
39
|
+
SOFT_INJECT_WARN_CHARS = MAX_SKILL_INJECT_CHARS # warn-only threshold at injection time
|
|
40
|
+
MAX_SKILL_CONTENT_CHARS = MAX_SKILL_FILE_CHARS # back-compat alias (no code imports this as of 2026-07 grep)
|
|
41
|
+
MIN_SKILL_CONTENT_CHARS = 50 # Minimum meaningful content
|
|
42
|
+
MAX_SKILL_ID_LENGTH = 50
|
|
43
|
+
VALID_TOOL_IDS = {'browser', 'mcp', 'filesystem', 'perplexity', 'email', 'task',
|
|
44
|
+
'anysite', 'coding', 'code_execution', 'polymarket', 'hyperliquid',
|
|
45
|
+
'polymarket_data', 'hyperliquid_data', 'web_fetch'}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_skill_content_length(body: str) -> Tuple[bool, str]:
|
|
49
|
+
"""Pure length check for a skill body (Task 7).
|
|
50
|
+
|
|
51
|
+
Encodes the ONE hard-reject window: [MIN_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_CHARS].
|
|
52
|
+
A body between MAX_SKILL_INJECT_CHARS and MAX_SKILL_FILE_CHARS is ACCEPTED here -
|
|
53
|
+
it may still land on disk / be read via a skill resource - callers that inject the
|
|
54
|
+
body into the prompt should separately warn (not reject) past MAX_SKILL_INJECT_CHARS;
|
|
55
|
+
see the warning emitted in format_skills_for_prompt().
|
|
56
|
+
"""
|
|
57
|
+
n = len(body)
|
|
58
|
+
if n < MIN_SKILL_CONTENT_CHARS:
|
|
59
|
+
return False, f"too short ({n}<{MIN_SKILL_CONTENT_CHARS})"
|
|
60
|
+
if n > MAX_SKILL_FILE_CHARS:
|
|
61
|
+
return False, f"too large ({n}>{MAX_SKILL_FILE_CHARS})"
|
|
62
|
+
return True, ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_skill_frontmatter(content: str) -> Tuple[Dict[str, str], str]:
|
|
66
|
+
"""Split an optional leading YAML frontmatter block from a skill body (P1-1).
|
|
67
|
+
|
|
68
|
+
Supports the agentskills.io / clawhub open standard where a SKILL.md opens with::
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
name: my-skill
|
|
72
|
+
description: ...
|
|
73
|
+
---
|
|
74
|
+
# Heading ...
|
|
75
|
+
|
|
76
|
+
Returns ``(frontmatter_dict, body)``. With no frontmatter, returns ``({}, content)``.
|
|
77
|
+
Parsing is intentionally dependency-free (simple ``key: value`` lines) so it never
|
|
78
|
+
pulls in a YAML lib; unparseable lines are skipped, not fatal.
|
|
79
|
+
"""
|
|
80
|
+
if not content:
|
|
81
|
+
return {}, content or ""
|
|
82
|
+
text = content.lstrip('') # tolerate a leading BOM
|
|
83
|
+
if not text.startswith('---'):
|
|
84
|
+
return {}, content
|
|
85
|
+
lines = text.splitlines()
|
|
86
|
+
# first line is the opening '---'; find the closing fence
|
|
87
|
+
for i in range(1, len(lines)):
|
|
88
|
+
if lines[i].strip() == '---':
|
|
89
|
+
fm: Dict[str, str] = {}
|
|
90
|
+
for raw in lines[1:i]:
|
|
91
|
+
if ':' in raw:
|
|
92
|
+
k, _, v = raw.partition(':')
|
|
93
|
+
key = k.strip()
|
|
94
|
+
if key:
|
|
95
|
+
fm[key] = v.strip().strip('"').strip("'")
|
|
96
|
+
body = '\n'.join(lines[i + 1:]).lstrip('\n')
|
|
97
|
+
return fm, body
|
|
98
|
+
return {}, content # no closing fence -> treat as plain content
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def strip_skill_frontmatter(content: str) -> str:
|
|
102
|
+
"""Return the skill body with any leading YAML frontmatter removed (P1-1)."""
|
|
103
|
+
return parse_skill_frontmatter(content)[1]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class SkillValidationResult:
|
|
108
|
+
"""Result of skill validation."""
|
|
109
|
+
skill_id: str
|
|
110
|
+
is_valid: bool
|
|
111
|
+
errors: List[str] = field(default_factory=list)
|
|
112
|
+
warnings: List[str] = field(default_factory=list)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class MatchedSkill:
|
|
117
|
+
"""A skill that matched the session context."""
|
|
118
|
+
skill_id: str
|
|
119
|
+
priority: int
|
|
120
|
+
match_reasons: List[str]
|
|
121
|
+
content: str
|
|
122
|
+
description: str = ""
|
|
123
|
+
trigger_type: str = "auto" # Primary trigger type that matched
|
|
124
|
+
# Task 14: provenance for externally-discovered (agentskills.io ecosystem) skills —
|
|
125
|
+
# "" for builtin/user, "user"/"project" for ~/.agents/skills etc. Purely informational.
|
|
126
|
+
source: str = ""
|
|
127
|
+
|
|
128
|
+
def __getitem__(self, key: str):
|
|
129
|
+
"""Dict-style access (``m["id"]``) alongside normal attribute access, so
|
|
130
|
+
catalog consumers can treat every entry uniformly regardless of whether
|
|
131
|
+
it originated from rules.json or from external skill discovery."""
|
|
132
|
+
if key == "id":
|
|
133
|
+
return self.skill_id
|
|
134
|
+
return getattr(self, key)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class SkillContext:
|
|
139
|
+
"""Context for skill matching - represents a session's configuration."""
|
|
140
|
+
tool_ids: List[str] = field(default_factory=list)
|
|
141
|
+
task: str = ""
|
|
142
|
+
available_actions: List[str] = field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
from agents.task.agent.skill_writer import SkillWriterMixin
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SkillManager(SkillWriterMixin):
|
|
149
|
+
"""
|
|
150
|
+
Manages skill loading and matching for sessions.
|
|
151
|
+
|
|
152
|
+
Each session calls get_skills_for_session() during initialization.
|
|
153
|
+
The returned skills are embedded into the system prompt.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
skill_manager = SkillManager()
|
|
157
|
+
|
|
158
|
+
# During session initialization:
|
|
159
|
+
matched_skills = skill_manager.get_skills_for_session(
|
|
160
|
+
tool_ids=['browser', 'mcp'],
|
|
161
|
+
task="research LinkedIn profiles and create report"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Format for prompt embedding:
|
|
165
|
+
skill_content = skill_manager.format_skills_for_prompt(matched_skills)
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, skills_dir: Optional[Path] = None):
|
|
169
|
+
"""Initialize SkillManager with skills directory.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
skills_dir: Path to the BUILTIN (system) skills directory. Defaults
|
|
173
|
+
to the shipped package's ``data/prompts/skills/`` (Task 8:
|
|
174
|
+
``skill_store.builtin_scope().root``). Per-tenant
|
|
175
|
+
``user_<uid>`` reads/writes do NOT use this by default — see
|
|
176
|
+
``_user_dirs_root()``, which routes them to the writable
|
|
177
|
+
data-home scope instead so they survive a ``polyrob update``
|
|
178
|
+
code-swap. Passing this explicitly (as several existing tests
|
|
179
|
+
do, to isolate BOTH builtin rules.json AND user writes under
|
|
180
|
+
one tmp root) also redirects user dirs here, preserving that
|
|
181
|
+
pre-existing single-root override contract.
|
|
182
|
+
"""
|
|
183
|
+
# Recorded once so `_user_dirs_root()` can detect an override of the
|
|
184
|
+
# TRUE builtin default — whether via this constructor arg or a later
|
|
185
|
+
# direct `.skills_dir =` mutation (both patterns are used by the
|
|
186
|
+
# existing test suite for single-root isolation).
|
|
187
|
+
self._builtin_default_dir = skill_store.builtin_scope().root
|
|
188
|
+
if skills_dir:
|
|
189
|
+
self.skills_dir = Path(skills_dir)
|
|
190
|
+
else:
|
|
191
|
+
self.skills_dir = self._builtin_default_dir
|
|
192
|
+
|
|
193
|
+
self.skill_rules: Dict[str, dict] = {}
|
|
194
|
+
self.skill_cache: Dict[str, str] = {}
|
|
195
|
+
# Parsed YAML frontmatter (agentskills.io) for each loaded skill, keyed by the
|
|
196
|
+
# same cache_key as skill_cache. Populated by _load_skill_content, consumed by
|
|
197
|
+
# _get_skill_meta() (e.g. catalog description preference in _resolve_skill_description).
|
|
198
|
+
self.skill_meta_cache: Dict[str, Dict[str, Any]] = {}
|
|
199
|
+
self._rules_loaded = False
|
|
200
|
+
# Task 14: lazy, once-per-instance cache of externally-discovered
|
|
201
|
+
# (~/.agents/skills, ~/.claude/skills, ...) skills. None = not yet computed;
|
|
202
|
+
# recomputed on next access after reload_rules() invalidates it.
|
|
203
|
+
self._external_index: Optional[Dict[str, "skill_discovery.DiscoveredSkill"]] = None
|
|
204
|
+
|
|
205
|
+
# Task 9: one-time migration of any pre-Task-8 code-tree user_<uid>/
|
|
206
|
+
# skills into data-home, so an existing deployment doesn't strand them
|
|
207
|
+
# the moment this class starts reading user skills from data-home
|
|
208
|
+
# instead of the package tree (see skill_store.py's migration section
|
|
209
|
+
# docstring). ALWAYS targets the REAL package builtin tree
|
|
210
|
+
# (self._builtin_default_dir), never a test's skills_dir override -
|
|
211
|
+
# migration is about where legacy skills physically shipped, not
|
|
212
|
+
# wherever a given SkillManager instance has been redirected to.
|
|
213
|
+
# Guarded: fail-open (never blocks construction), and the migration
|
|
214
|
+
# itself is idempotent/locked/resumable (see migrate_legacy_user_skills).
|
|
215
|
+
try:
|
|
216
|
+
_moved = skill_store.migrate_legacy_user_skills(self._builtin_default_dir)
|
|
217
|
+
if _moved:
|
|
218
|
+
logger.info("migrated %d legacy user skill(s) into data-home", _moved)
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.debug("legacy skill migration skipped (non-fatal)", exc_info=True)
|
|
221
|
+
|
|
222
|
+
logger.debug(f"SkillManager initialized with skills_dir: {self.skills_dir}")
|
|
223
|
+
|
|
224
|
+
def _load_external_skills(self) -> Dict[str, Any]:
|
|
225
|
+
"""Discover + precedence-dedupe external (agentskills.io ecosystem) skills.
|
|
226
|
+
|
|
227
|
+
Project roots (higher precedence) are consulted ONLY when
|
|
228
|
+
``skill_discovery.trust_project_skills_effective()`` says so (Task 15 —
|
|
229
|
+
that function does not exist yet, so this is guarded and inert until
|
|
230
|
+
then); user roots (``~/.agents/skills``, ``~/.claude/skills``) are
|
|
231
|
+
always consulted. Cached once per instance; call ``reload_rules()`` to
|
|
232
|
+
force a re-scan.
|
|
233
|
+
"""
|
|
234
|
+
if self._external_index is not None:
|
|
235
|
+
return self._external_index
|
|
236
|
+
from . import skill_discovery
|
|
237
|
+
# Task 16: builtin ids are PROTECTED — an external (ecosystem) skill can
|
|
238
|
+
# never enter the index under a builtin's name, no matter its scope, so
|
|
239
|
+
# it can never shadow the builtin body nor appear in its place in the
|
|
240
|
+
# catalog. Fail-open: if this lookup errors, treat as "no protected ids"
|
|
241
|
+
# rather than blocking external discovery.
|
|
242
|
+
try:
|
|
243
|
+
protected_ids = set(skill_store.builtin_skill_ids())
|
|
244
|
+
except Exception:
|
|
245
|
+
logger.debug("builtin-id lookup skipped (non-fatal)", exc_info=True)
|
|
246
|
+
protected_ids = set()
|
|
247
|
+
index: Dict[str, Any] = {}
|
|
248
|
+
roots = []
|
|
249
|
+
try:
|
|
250
|
+
if skill_discovery.trust_project_skills_effective():
|
|
251
|
+
roots += [("project", r) for r in skill_discovery.project_external_roots()]
|
|
252
|
+
except Exception:
|
|
253
|
+
logger.debug("project-skill discovery skipped", exc_info=True)
|
|
254
|
+
roots += [("user", r) for r in skill_discovery.user_external_roots()]
|
|
255
|
+
for scope, root in roots: # project-first => higher precedence
|
|
256
|
+
for ds in skill_discovery.discover_skills(root, scope):
|
|
257
|
+
if ds.skill_id in protected_ids:
|
|
258
|
+
logger.warning("skill collision: %r in %s shadowed by protected builtin id %r",
|
|
259
|
+
ds.skill_id, ds.path, ds.skill_id)
|
|
260
|
+
continue
|
|
261
|
+
if ds.skill_id in index:
|
|
262
|
+
logger.warning("skill collision: %r in %s shadowed by higher-precedence %s",
|
|
263
|
+
ds.skill_id, ds.path, index[ds.skill_id].path)
|
|
264
|
+
continue
|
|
265
|
+
index[ds.skill_id] = ds
|
|
266
|
+
self._external_index = index
|
|
267
|
+
return index
|
|
268
|
+
|
|
269
|
+
def _ensure_rules_loaded(self) -> None:
|
|
270
|
+
"""Load skill rules from rules.json if not already loaded."""
|
|
271
|
+
if self._rules_loaded:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
rules_file = self.skills_dir / "rules.json"
|
|
275
|
+
if rules_file.exists():
|
|
276
|
+
try:
|
|
277
|
+
with open(rules_file, 'r') as f:
|
|
278
|
+
self.skill_rules = json.load(f)
|
|
279
|
+
logger.info(f"Loaded {len(self.skill_rules)} skill rules from {rules_file}")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"Failed to load skill rules: {e}")
|
|
282
|
+
self.skill_rules = {}
|
|
283
|
+
else:
|
|
284
|
+
logger.warning(f"No skill rules file found at {rules_file}")
|
|
285
|
+
self.skill_rules = {}
|
|
286
|
+
|
|
287
|
+
# P0-2: drift guard. A rule with auto_activate but no on-disk SKILL.md body is a
|
|
288
|
+
# SILENT failure (matches, then drops with only a WARNING). Prune such orphans
|
|
289
|
+
# from the in-memory rules so they can never burn match work or mislead the
|
|
290
|
+
# catalog. Fail-open — a content gap must never block boot.
|
|
291
|
+
try:
|
|
292
|
+
self._prune_bodiless_rules()
|
|
293
|
+
except Exception as e: # pragma: no cover - defensive
|
|
294
|
+
logger.debug(f"orphan-rule prune skipped (non-fatal): {e}")
|
|
295
|
+
|
|
296
|
+
# Task 4: fail-open boot warning for strict agentskills.io frontmatter
|
|
297
|
+
# compliance. Observability only — a non-compliant skill (or the check
|
|
298
|
+
# itself erroring) must NEVER block startup.
|
|
299
|
+
try:
|
|
300
|
+
bad = self.validate_all_authored()
|
|
301
|
+
if bad:
|
|
302
|
+
logger.warning("skill compliance: %d non-compliant skill(s): %s",
|
|
303
|
+
len(bad), ", ".join(sorted(bad)))
|
|
304
|
+
except Exception:
|
|
305
|
+
logger.debug("skill compliance check skipped", exc_info=True)
|
|
306
|
+
|
|
307
|
+
self._rules_loaded = True
|
|
308
|
+
|
|
309
|
+
def _prune_bodiless_rules(self) -> None:
|
|
310
|
+
"""Drop any auto_activate system rule whose SKILL.md body is missing."""
|
|
311
|
+
orphans = [
|
|
312
|
+
sid for sid, rules in self.skill_rules.items()
|
|
313
|
+
if rules.get("auto_activate", True)
|
|
314
|
+
and not (self.skills_dir / sid / "SKILL.md").exists()
|
|
315
|
+
]
|
|
316
|
+
if not orphans:
|
|
317
|
+
return
|
|
318
|
+
try:
|
|
319
|
+
from agents.task.constants import local_mode_enabled
|
|
320
|
+
severe = local_mode_enabled()
|
|
321
|
+
except Exception:
|
|
322
|
+
severe = False
|
|
323
|
+
msg = (f"Skill rules reference {len(orphans)} auto_activate id(s) with no "
|
|
324
|
+
f"SKILL.md body — pruning so they can't silent-drop: {orphans}")
|
|
325
|
+
(logger.error if severe else logger.warning)(msg)
|
|
326
|
+
for sid in orphans:
|
|
327
|
+
self.skill_rules.pop(sid, None)
|
|
328
|
+
|
|
329
|
+
def get_skills_for_session(
|
|
330
|
+
self,
|
|
331
|
+
tool_ids: Optional[List[str]] = None,
|
|
332
|
+
task: str = "",
|
|
333
|
+
available_actions: Optional[List[str]] = None,
|
|
334
|
+
max_skills: int = 2, # Reduced from 5 to prevent prompt bloat
|
|
335
|
+
user_id: Optional[str] = None,
|
|
336
|
+
seeded_skill_ids: Optional[List[str]] = None,
|
|
337
|
+
) -> List[MatchedSkill]:
|
|
338
|
+
"""
|
|
339
|
+
Get skills that match the session's configuration.
|
|
340
|
+
|
|
341
|
+
This is the main entry point - call during session initialization.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
tool_ids: List of tools loaded for this session (e.g., ['browser', 'mcp'])
|
|
345
|
+
task: The task description for this session
|
|
346
|
+
available_actions: List of available action names
|
|
347
|
+
max_skills: Maximum number of skills to return
|
|
348
|
+
user_id: Optional user ID to include user's custom skills
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of MatchedSkill objects sorted by priority
|
|
352
|
+
"""
|
|
353
|
+
self._ensure_rules_loaded()
|
|
354
|
+
|
|
355
|
+
tool_ids = tool_ids or []
|
|
356
|
+
available_actions = available_actions or []
|
|
357
|
+
task_lower = task.lower()
|
|
358
|
+
|
|
359
|
+
# Combine system + user rules
|
|
360
|
+
all_rules = dict(self.skill_rules)
|
|
361
|
+
user_skills_dir = None
|
|
362
|
+
|
|
363
|
+
if user_id:
|
|
364
|
+
user_rules, user_skills_dir = self._load_user_rules(user_id)
|
|
365
|
+
all_rules.update(user_rules)
|
|
366
|
+
|
|
367
|
+
matches = []
|
|
368
|
+
|
|
369
|
+
for skill_id, rules in all_rules.items():
|
|
370
|
+
if not rules.get("auto_activate", True):
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
triggers = rules.get("triggers", {})
|
|
374
|
+
|
|
375
|
+
# Collect matches by type
|
|
376
|
+
tool_matches = []
|
|
377
|
+
keyword_matches = []
|
|
378
|
+
pattern_matches = []
|
|
379
|
+
action_matches = []
|
|
380
|
+
|
|
381
|
+
# Check tool ID matching
|
|
382
|
+
trigger_tool_ids = triggers.get("tool_ids", [])
|
|
383
|
+
for tool_id in trigger_tool_ids:
|
|
384
|
+
if tool_id in tool_ids:
|
|
385
|
+
tool_matches.append(f"tool:{tool_id}")
|
|
386
|
+
|
|
387
|
+
# Check keyword matching in task. P2-19: WORD-BOUNDARY match, not a raw
|
|
388
|
+
# substring — a short keyword like "sell"/"buy"/"trade"/"plan"/"fix" used
|
|
389
|
+
# to fire on "counselling"/"busy"/"airplane"/"prefix" substrings, and a
|
|
390
|
+
# priority-1 false positive could evict a genuinely relevant skill under the
|
|
391
|
+
# max_skills cap. re.escape handles punctuation; a multi-word keyword is
|
|
392
|
+
# matched as a phrase bounded at each end.
|
|
393
|
+
keywords = triggers.get("keywords", [])
|
|
394
|
+
for kw in keywords:
|
|
395
|
+
kw_l = (kw or "").lower().strip()
|
|
396
|
+
if not kw_l:
|
|
397
|
+
continue
|
|
398
|
+
# P2-19 (Fusion follow-up): `\b` only asserts a boundary NEXT TO a word
|
|
399
|
+
# char, so a keyword that starts or ends with a non-word char ("c++",
|
|
400
|
+
# ".net") gets NO boundary there and `\b<kw>\b` never matches — a silent
|
|
401
|
+
# false-negative the re.error fallback (zero matches, not an error) can't
|
|
402
|
+
# catch. Anchor `\b` conditionally: only at an end whose adjacent keyword
|
|
403
|
+
# char is a word char. So "sell" -> `\bsell\b` (fixes the "counselling"
|
|
404
|
+
# false positive) but "c++" -> `\bc\+\+` (still matches "use c++ here").
|
|
405
|
+
def _wordish(ch: str) -> bool:
|
|
406
|
+
return ch.isalnum() or ch == "_"
|
|
407
|
+
left = r"\b" if _wordish(kw_l[0]) else ""
|
|
408
|
+
right = r"\b" if _wordish(kw_l[-1]) else ""
|
|
409
|
+
try:
|
|
410
|
+
if re.search(left + re.escape(kw_l) + right, task_lower):
|
|
411
|
+
keyword_matches.append(f"keyword:{kw}")
|
|
412
|
+
except re.error:
|
|
413
|
+
# Degenerate keyword -> fall back to substring (never crash matching)
|
|
414
|
+
if kw_l in task_lower:
|
|
415
|
+
keyword_matches.append(f"keyword:{kw}")
|
|
416
|
+
|
|
417
|
+
# Check action name matching
|
|
418
|
+
action_names = triggers.get("action_names", [])
|
|
419
|
+
for action in action_names:
|
|
420
|
+
if action in available_actions:
|
|
421
|
+
action_matches.append(f"action:{action}")
|
|
422
|
+
|
|
423
|
+
# Check task pattern matching (regex)
|
|
424
|
+
task_patterns = triggers.get("task_patterns", [])
|
|
425
|
+
for pattern in task_patterns:
|
|
426
|
+
try:
|
|
427
|
+
if re.search(pattern, task, re.IGNORECASE):
|
|
428
|
+
pattern_matches.append(f"pattern:{pattern}")
|
|
429
|
+
except re.error:
|
|
430
|
+
pass # Invalid regex, skip
|
|
431
|
+
|
|
432
|
+
# CRITICAL: Determine if skill should load based on RELEVANCE to task
|
|
433
|
+
# - Keyword or pattern match = task is relevant → LOAD
|
|
434
|
+
# - Tool match ONLY (no keyword/pattern) = tool present but task not relevant → SKIP
|
|
435
|
+
# - Tool match + keyword/pattern = strongly relevant → LOAD
|
|
436
|
+
has_task_relevance = bool(keyword_matches or pattern_matches)
|
|
437
|
+
has_tool_match = bool(tool_matches)
|
|
438
|
+
|
|
439
|
+
# Only load if task is actually relevant (keyword/pattern match required)
|
|
440
|
+
# Exception: if skill has no tool_ids defined, action match alone can trigger
|
|
441
|
+
trigger_tool_ids_defined = bool(triggers.get("tool_ids", []))
|
|
442
|
+
|
|
443
|
+
should_load = False
|
|
444
|
+
if has_task_relevance:
|
|
445
|
+
# Task mentions relevant keywords/patterns - load this skill
|
|
446
|
+
should_load = True
|
|
447
|
+
elif not trigger_tool_ids_defined and action_matches:
|
|
448
|
+
# Skill doesn't require specific tools and has matching actions
|
|
449
|
+
should_load = True
|
|
450
|
+
# Note: tool_match alone is NOT enough - prevents loading person-analyzer
|
|
451
|
+
# just because MCP is present when task is about presentations
|
|
452
|
+
|
|
453
|
+
if should_load:
|
|
454
|
+
match_reasons = tool_matches + keyword_matches + pattern_matches + action_matches
|
|
455
|
+
content = self._load_skill_content(skill_id, user_id=user_id)
|
|
456
|
+
if content:
|
|
457
|
+
# Determine primary trigger type
|
|
458
|
+
trigger_type = "auto"
|
|
459
|
+
if any(r.startswith("tool:") for r in match_reasons):
|
|
460
|
+
trigger_type = "tool"
|
|
461
|
+
elif any(r.startswith("keyword:") for r in match_reasons):
|
|
462
|
+
trigger_type = "keyword"
|
|
463
|
+
elif any(r.startswith("action:") for r in match_reasons):
|
|
464
|
+
trigger_type = "action"
|
|
465
|
+
elif any(r.startswith("pattern:") for r in match_reasons):
|
|
466
|
+
trigger_type = "pattern"
|
|
467
|
+
|
|
468
|
+
matches.append(MatchedSkill(
|
|
469
|
+
skill_id=skill_id,
|
|
470
|
+
priority=rules.get("priority", 5),
|
|
471
|
+
match_reasons=match_reasons,
|
|
472
|
+
content=content,
|
|
473
|
+
description=self._resolve_skill_description(skill_id, rules, user_id=user_id),
|
|
474
|
+
trigger_type=trigger_type
|
|
475
|
+
))
|
|
476
|
+
|
|
477
|
+
# Sort by priority (lower = higher priority), then by number of matches
|
|
478
|
+
matches.sort(key=lambda m: (m.priority, -len(m.match_reasons)))
|
|
479
|
+
|
|
480
|
+
result = matches[:max_skills]
|
|
481
|
+
|
|
482
|
+
# Force-include preset-seeded skills regardless of trigger match.
|
|
483
|
+
# Seeds bypass max_skills truncation (they are explicit user intent).
|
|
484
|
+
if seeded_skill_ids:
|
|
485
|
+
present = {m.skill_id for m in result}
|
|
486
|
+
for sid in seeded_skill_ids:
|
|
487
|
+
if sid in present:
|
|
488
|
+
continue # already in result (via trigger match) → dedup
|
|
489
|
+
rules = all_rules.get(sid)
|
|
490
|
+
if not rules:
|
|
491
|
+
continue # unknown id → skip (fail-open)
|
|
492
|
+
content = self._load_skill_content(sid, user_id=user_id)
|
|
493
|
+
if not content:
|
|
494
|
+
continue # no SKILL.md → skip (fail-open)
|
|
495
|
+
result.append(MatchedSkill(
|
|
496
|
+
skill_id=sid,
|
|
497
|
+
priority=rules.get("priority", 99),
|
|
498
|
+
match_reasons=["seeded:preset"],
|
|
499
|
+
content=content,
|
|
500
|
+
description=self._resolve_skill_description(sid, rules, user_id=user_id),
|
|
501
|
+
trigger_type="seeded",
|
|
502
|
+
))
|
|
503
|
+
|
|
504
|
+
if result:
|
|
505
|
+
logger.info(
|
|
506
|
+
f"Matched {len(result)} skills for session: "
|
|
507
|
+
f"{[m.skill_id for m in result]}"
|
|
508
|
+
)
|
|
509
|
+
elif task_lower.strip():
|
|
510
|
+
# P2-1b: a non-trivial task that trigger-matched nothing is a SILENT recall
|
|
511
|
+
# miss. Make it observable. (With catalog-include-all default-ON the agent can
|
|
512
|
+
# still discover these via load_skill, but the trigger miss itself is signal.)
|
|
513
|
+
available = sorted(
|
|
514
|
+
sid for sid, r in all_rules.items() if r.get("auto_activate", True)
|
|
515
|
+
)
|
|
516
|
+
if available:
|
|
517
|
+
logger.info(
|
|
518
|
+
"no skills matched the task; %d available but un-surfaced by triggers: %s",
|
|
519
|
+
len(available), available,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
def get_catalog_skills(
|
|
525
|
+
self,
|
|
526
|
+
user_id: Optional[str] = None,
|
|
527
|
+
max_skills: int = 20,
|
|
528
|
+
tool_ids: Optional[List[str]] = None,
|
|
529
|
+
) -> List["MatchedSkill"]:
|
|
530
|
+
"""Return auto-activatable skills as catalog entries (S-1, true progressive disclosure).
|
|
531
|
+
|
|
532
|
+
Unlike :meth:`get_skills_for_session` (which trigger-matches against the task),
|
|
533
|
+
this exposes every available skill so the agent can DISCOVER and ``load_skill``
|
|
534
|
+
any of them on demand — fixing the gap where a session that matched zero
|
|
535
|
+
triggers got an empty catalog and could load nothing. Bodies are preloaded so
|
|
536
|
+
``load_skill`` serves without a disk re-read. Sorted by priority, capped.
|
|
537
|
+
|
|
538
|
+
P1-1: a GATED skill (``auto_activate: false`` — e.g. the money/trading
|
|
539
|
+
playbooks) is surfaced ONLY when the session has loaded its required tools
|
|
540
|
+
(``triggers.tool_ids`` all present in *tool_ids*) — that is what those triggers
|
|
541
|
+
were written for. Otherwise it stays hidden AND the load_skill fallback refuses
|
|
542
|
+
it (see :meth:`may_load_skill`), so the gate is real, not advisory.
|
|
543
|
+
"""
|
|
544
|
+
self._ensure_rules_loaded()
|
|
545
|
+
all_rules = dict(self.skill_rules)
|
|
546
|
+
if user_id:
|
|
547
|
+
user_rules, _ = self._load_user_rules(user_id)
|
|
548
|
+
all_rules.update(user_rules)
|
|
549
|
+
|
|
550
|
+
session_tool_ids = set(tool_ids or [])
|
|
551
|
+
catalog = []
|
|
552
|
+
for skill_id, rules in all_rules.items():
|
|
553
|
+
if not rules.get("auto_activate", True):
|
|
554
|
+
# P1-1: surface a gated skill only when its required tools are loaded.
|
|
555
|
+
gate_tool_ids = set(rules.get("triggers", {}).get("tool_ids", []))
|
|
556
|
+
if not (gate_tool_ids and gate_tool_ids.issubset(session_tool_ids)):
|
|
557
|
+
continue
|
|
558
|
+
content = self._load_skill_content(skill_id, user_id=user_id)
|
|
559
|
+
if not content:
|
|
560
|
+
continue
|
|
561
|
+
catalog.append(MatchedSkill(
|
|
562
|
+
skill_id=skill_id,
|
|
563
|
+
priority=rules.get("priority", 5),
|
|
564
|
+
match_reasons=["catalog"],
|
|
565
|
+
content=content,
|
|
566
|
+
description=self._resolve_skill_description(skill_id, rules, user_id=user_id),
|
|
567
|
+
trigger_type="catalog",
|
|
568
|
+
))
|
|
569
|
+
|
|
570
|
+
# Task 14: append externally-discovered (agentskills.io ecosystem) skills that
|
|
571
|
+
# aren't already covered by a builtin/user rule of the same id.
|
|
572
|
+
# P1-7: external skills (from ~/.agents/skills, ~/.claude/skills) are untrusted
|
|
573
|
+
# third-party content that gets pinned into EVERY session's catalog prompt —
|
|
574
|
+
# scan the description AND body, and skip any that trips the injection scan
|
|
575
|
+
# (fail-OPEN if the scanner is unavailable, fail-CLOSED if it raises), matching
|
|
576
|
+
# the writer's P3-1 stance that a catalog description is an injection vector.
|
|
577
|
+
existing_ids = {m.skill_id for m in catalog}
|
|
578
|
+
for ext_id, ds in self._load_external_skills().items():
|
|
579
|
+
if ext_id in existing_ids:
|
|
580
|
+
continue
|
|
581
|
+
ext_desc = ds.meta.get("description", "")
|
|
582
|
+
if self._external_content_suspicious(ext_id, ext_desc, ds.body):
|
|
583
|
+
continue
|
|
584
|
+
catalog.append(MatchedSkill(
|
|
585
|
+
skill_id=ext_id,
|
|
586
|
+
priority=5,
|
|
587
|
+
match_reasons=["catalog"],
|
|
588
|
+
content=ds.body,
|
|
589
|
+
description=ext_desc,
|
|
590
|
+
trigger_type="catalog",
|
|
591
|
+
source=ds.scope,
|
|
592
|
+
))
|
|
593
|
+
existing_ids.add(ext_id)
|
|
594
|
+
|
|
595
|
+
catalog.sort(key=lambda m: (m.priority, m.skill_id))
|
|
596
|
+
return catalog[:max_skills]
|
|
597
|
+
|
|
598
|
+
@staticmethod
|
|
599
|
+
def _external_content_suspicious(skill_id: str, description: str, body: str) -> bool:
|
|
600
|
+
"""True if an external skill's description/body trips the injection scan (P1-7).
|
|
601
|
+
|
|
602
|
+
Fail-OPEN when the scanner can't be imported (parity with the rest of the
|
|
603
|
+
codebase); fail-CLOSED (treat as suspicious) when the scanner itself raises.
|
|
604
|
+
"""
|
|
605
|
+
try:
|
|
606
|
+
from modules.memory.task.threat_scan import is_suspicious
|
|
607
|
+
except Exception:
|
|
608
|
+
return False # scanner unavailable → fail-open
|
|
609
|
+
try:
|
|
610
|
+
combined = f"{description}\n\n{body}"
|
|
611
|
+
if is_suspicious(combined):
|
|
612
|
+
logger.warning(
|
|
613
|
+
"external skill %r excluded from catalog: content tripped the "
|
|
614
|
+
"injection scan", skill_id,
|
|
615
|
+
)
|
|
616
|
+
return True
|
|
617
|
+
return False
|
|
618
|
+
except Exception:
|
|
619
|
+
logger.warning(
|
|
620
|
+
"external skill %r excluded from catalog: injection scan raised "
|
|
621
|
+
"(fail-closed)", skill_id,
|
|
622
|
+
)
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
def _user_dirs_root(self) -> Path:
|
|
626
|
+
"""Root under which per-tenant ``user_<uid>`` skill directories live (Task 8).
|
|
627
|
+
|
|
628
|
+
Defaults to the WRITABLE data-home user scope
|
|
629
|
+
(``skill_store.skills_data_home()``) so a create/patch/delete survives
|
|
630
|
+
a ``polyrob update`` code-tree swap — the installed package tree
|
|
631
|
+
(``self.skills_dir``'s default) is replaced wholesale on update, but
|
|
632
|
+
data-home is not.
|
|
633
|
+
|
|
634
|
+
If ``skills_dir`` has been redirected away from the true builtin
|
|
635
|
+
default — via the constructor arg or by mutating ``.skills_dir``
|
|
636
|
+
directly (the single-root isolation pattern several existing tests
|
|
637
|
+
use) — that redirected root governs user dirs too, so those tests
|
|
638
|
+
keep working unchanged. This is a deliberate, dynamically-re-checked
|
|
639
|
+
comparison (not a flag frozen at ``__init__`` time) so a POST-construction
|
|
640
|
+
mutation of ``.skills_dir`` (as `test_skill_overwrite_protect.py` does)
|
|
641
|
+
is honored too.
|
|
642
|
+
"""
|
|
643
|
+
if self.skills_dir != self._builtin_default_dir:
|
|
644
|
+
return self.skills_dir
|
|
645
|
+
return skill_store.skills_data_home()
|
|
646
|
+
|
|
647
|
+
def resolve_skill_dir(self, skill_id: str, user_id: Optional[str] = None) -> Optional[Path]:
|
|
648
|
+
"""Resolve a skill's on-disk directory for read-only resource access (Task 17).
|
|
649
|
+
|
|
650
|
+
Precedence: builtin (``skills_dir/<skill_id>``, if it has a
|
|
651
|
+
``SKILL.md`` — matches ``_load_skill_content``'s lookup, so a
|
|
652
|
+
``SkillManager(skills_dir=custom)`` test-isolation override resolves
|
|
653
|
+
consistently here too) > per-tenant user dir
|
|
654
|
+
(``_user_dirs_root()/user_<uid>/<skill_id>``, if it exists) >
|
|
655
|
+
externally-discovered (``~/.agents/skills``, ``~/.claude/skills``,
|
|
656
|
+
...) skill's ``.path``. Returns ``None`` if the skill can't be located on disk
|
|
657
|
+
(e.g. an in-memory-only / test-injected skill) — fail-open, never raises.
|
|
658
|
+
|
|
659
|
+
Guards against path escape up front: a falsy/empty, multi-segment
|
|
660
|
+
(contains ``/`` or ``\\``), parent-traversing (``..``), or
|
|
661
|
+
whitespace-padded ``skill_id`` returns ``None`` immediately. This is
|
|
662
|
+
deliberately looser than ``validate_skill_id`` — it must still accept
|
|
663
|
+
legit lenient external ids (digit-leading, unicode, e.g.
|
|
664
|
+
``3d-modeling``), just refuse anything that could escape the
|
|
665
|
+
directory join below.
|
|
666
|
+
|
|
667
|
+
Reused by Task 18 (list/read wiring) — do not duplicate this lookup elsewhere.
|
|
668
|
+
"""
|
|
669
|
+
if (
|
|
670
|
+
not skill_id
|
|
671
|
+
or "/" in skill_id
|
|
672
|
+
or "\\" in skill_id
|
|
673
|
+
or ".." in skill_id
|
|
674
|
+
or skill_id != skill_id.strip()
|
|
675
|
+
):
|
|
676
|
+
return None
|
|
677
|
+
try:
|
|
678
|
+
# Use self.skills_dir (not self._builtin_default_dir) so a
|
|
679
|
+
# SkillManager(skills_dir=custom) test-isolation override is
|
|
680
|
+
# honored here too, matching _load_skill_content's lookup.
|
|
681
|
+
builtin_dir = self.skills_dir / skill_id
|
|
682
|
+
if (builtin_dir / "SKILL.md").exists():
|
|
683
|
+
return builtin_dir
|
|
684
|
+
except OSError:
|
|
685
|
+
pass
|
|
686
|
+
if user_id:
|
|
687
|
+
try:
|
|
688
|
+
user_dir = self._user_dirs_root() / f"user_{user_id}" / skill_id
|
|
689
|
+
if user_dir.exists():
|
|
690
|
+
return user_dir
|
|
691
|
+
except OSError:
|
|
692
|
+
pass
|
|
693
|
+
try:
|
|
694
|
+
ext = self._load_external_skills().get(skill_id)
|
|
695
|
+
if ext is not None:
|
|
696
|
+
return ext.path
|
|
697
|
+
except Exception:
|
|
698
|
+
logger.debug("resolve_skill_dir: external skill lookup failed for %s", skill_id, exc_info=True)
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
def _load_user_rules(self, user_id: str) -> tuple:
|
|
702
|
+
"""Load user's custom skill rules.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
user_id: User identifier
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Tuple of (rules dict, user skills directory Path)
|
|
709
|
+
"""
|
|
710
|
+
user_dir = self._user_dirs_root() / f"user_{user_id}"
|
|
711
|
+
rules_file = user_dir / "rules.json"
|
|
712
|
+
|
|
713
|
+
if rules_file.exists():
|
|
714
|
+
try:
|
|
715
|
+
with open(rules_file) as f:
|
|
716
|
+
rules = json.load(f)
|
|
717
|
+
logger.debug(f"Loaded {len(rules)} user skill rules for {user_id}")
|
|
718
|
+
return rules, user_dir
|
|
719
|
+
except Exception as e:
|
|
720
|
+
logger.warning(f"Failed to load user rules for {user_id}: {e}")
|
|
721
|
+
|
|
722
|
+
return {}, user_dir
|
|
723
|
+
|
|
724
|
+
def get_skill_rule(self, skill_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
|
725
|
+
"""Return the merged rule dict for a skill_id (user rule shadows builtin), or None."""
|
|
726
|
+
self._ensure_rules_loaded()
|
|
727
|
+
if user_id:
|
|
728
|
+
user_rules, _ = self._load_user_rules(user_id)
|
|
729
|
+
if skill_id in user_rules:
|
|
730
|
+
return user_rules[skill_id]
|
|
731
|
+
return self.skill_rules.get(skill_id)
|
|
732
|
+
|
|
733
|
+
def may_load_skill(
|
|
734
|
+
self, skill_id: str, tool_ids: Optional[List[str]] = None,
|
|
735
|
+
user_id: Optional[str] = None,
|
|
736
|
+
) -> bool:
|
|
737
|
+
"""Gate for on-demand loading (P1-1).
|
|
738
|
+
|
|
739
|
+
An ``auto_activate: false`` skill (e.g. the money/trading playbooks) is a GATED
|
|
740
|
+
skill: it may be loaded ONLY when the session has loaded its required tools
|
|
741
|
+
(``triggers.tool_ids`` all present). Otherwise the gate would only hide the
|
|
742
|
+
skill from the catalog while the load_skill disk fallback served the full
|
|
743
|
+
playbook to any model that guessed the id. An unknown id / any auto_activate
|
|
744
|
+
skill is loadable (True) — the fallback's own tenant/path guards still apply.
|
|
745
|
+
"""
|
|
746
|
+
rule = self.get_skill_rule(skill_id, user_id=user_id)
|
|
747
|
+
if rule is None:
|
|
748
|
+
return True # unknown to rules.json — not a gated skill; other guards apply
|
|
749
|
+
if rule.get("auto_activate", True):
|
|
750
|
+
return True
|
|
751
|
+
gate_tool_ids = set(rule.get("triggers", {}).get("tool_ids", []))
|
|
752
|
+
return bool(gate_tool_ids and gate_tool_ids.issubset(set(tool_ids or [])))
|
|
753
|
+
|
|
754
|
+
def _load_skill_content(self, skill_id: str, user_id: Optional[str] = None) -> str:
|
|
755
|
+
"""Load skill markdown content from disk, stripped of any YAML frontmatter.
|
|
756
|
+
|
|
757
|
+
SKILL.md may open with an agentskills.io-style frontmatter block
|
|
758
|
+
(``---\\nname: ...\\n---``). That block is metadata for tooling, not
|
|
759
|
+
instructions for the LLM, so it is parsed off here — the single source
|
|
760
|
+
point — and cached separately (see ``_get_skill_meta``); only the
|
|
761
|
+
frontmatter-free body is cached/returned. Every consumer of the returned
|
|
762
|
+
content (format_skills_for_prompt, build_load_skill_result,
|
|
763
|
+
validate_skill_content's content_len/estimated_tokens) therefore sees a
|
|
764
|
+
clean body without having to strip anything itself.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
skill_id: The skill identifier (directory name)
|
|
768
|
+
user_id: Optional user ID to check user skills first
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
Skill content as string (frontmatter stripped), or empty string if not found
|
|
772
|
+
"""
|
|
773
|
+
# Create cache key that includes user context
|
|
774
|
+
cache_key = f"{user_id}:{skill_id}" if user_id else skill_id
|
|
775
|
+
|
|
776
|
+
# Check cache first
|
|
777
|
+
if cache_key in self.skill_cache:
|
|
778
|
+
return self.skill_cache[cache_key]
|
|
779
|
+
|
|
780
|
+
# Check user skills first if user_id provided
|
|
781
|
+
if user_id:
|
|
782
|
+
user_skill_file = self._user_dirs_root() / f"user_{user_id}" / skill_id / "SKILL.md"
|
|
783
|
+
if user_skill_file.exists():
|
|
784
|
+
try:
|
|
785
|
+
raw = user_skill_file.read_text(encoding='utf-8')
|
|
786
|
+
meta, body = parse_frontmatter(raw)
|
|
787
|
+
self.skill_meta_cache[cache_key] = meta
|
|
788
|
+
self.skill_cache[cache_key] = body
|
|
789
|
+
logger.debug(f"Loaded user skill content for '{skill_id}' ({len(body)} chars)")
|
|
790
|
+
return body
|
|
791
|
+
except Exception as e:
|
|
792
|
+
logger.error(f"Failed to load user skill content for '{skill_id}': {e}")
|
|
793
|
+
|
|
794
|
+
# Try to load from system skill directory
|
|
795
|
+
skill_dir = self.skills_dir / skill_id
|
|
796
|
+
skill_file = skill_dir / "SKILL.md"
|
|
797
|
+
|
|
798
|
+
if skill_file.exists():
|
|
799
|
+
try:
|
|
800
|
+
raw = skill_file.read_text(encoding='utf-8')
|
|
801
|
+
meta, body = parse_frontmatter(raw)
|
|
802
|
+
self.skill_meta_cache[cache_key] = meta
|
|
803
|
+
self.skill_cache[cache_key] = body
|
|
804
|
+
logger.debug(f"Loaded skill content for '{skill_id}' ({len(body)} chars)")
|
|
805
|
+
return body
|
|
806
|
+
except Exception as e:
|
|
807
|
+
logger.error(f"Failed to load skill content for '{skill_id}': {e}")
|
|
808
|
+
else:
|
|
809
|
+
logger.warning(f"Skill file not found: {skill_file}")
|
|
810
|
+
|
|
811
|
+
# Task 14: external (~/.agents/skills, ~/.claude/skills, ...) lookup — LAST,
|
|
812
|
+
# so builtin/user skills always take precedence over ecosystem ones.
|
|
813
|
+
ext = self._load_external_skills().get(skill_id)
|
|
814
|
+
if ext is not None:
|
|
815
|
+
self.skill_meta_cache[cache_key] = ext.meta
|
|
816
|
+
self.skill_cache[cache_key] = ext.body
|
|
817
|
+
logger.debug(f"Loaded external ({ext.scope}) skill content for '{skill_id}' ({len(ext.body)} chars)")
|
|
818
|
+
return ext.body
|
|
819
|
+
|
|
820
|
+
return ""
|
|
821
|
+
|
|
822
|
+
def _get_skill_meta(self, skill_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
823
|
+
"""Return the parsed YAML frontmatter for a skill (populated as a side effect
|
|
824
|
+
of ``_load_skill_content``). Empty dict if the skill has no frontmatter, was
|
|
825
|
+
not found, or has not been loaded yet in this process.
|
|
826
|
+
"""
|
|
827
|
+
cache_key = f"{user_id}:{skill_id}" if user_id else skill_id
|
|
828
|
+
return self.skill_meta_cache.get(cache_key, {})
|
|
829
|
+
|
|
830
|
+
def _resolve_skill_description(self, skill_id: str, rules: dict,
|
|
831
|
+
user_id: Optional[str] = None) -> str:
|
|
832
|
+
"""Resolve the catalog-facing description for a skill.
|
|
833
|
+
|
|
834
|
+
The SKILL.md frontmatter ``description`` is the agentskills.io-compliant
|
|
835
|
+
source of truth and is preferred when present (non-empty); the rules.json
|
|
836
|
+
``description`` is the fallback (pre-frontmatter behavior, and the only
|
|
837
|
+
option for skills / callers with no parsed frontmatter). Callers MUST call
|
|
838
|
+
``_load_skill_content(skill_id, user_id=user_id)`` first so the frontmatter
|
|
839
|
+
cache is populated. This does not affect gating — auto_activate/priority/
|
|
840
|
+
triggers keep reading from ``rules`` untouched.
|
|
841
|
+
"""
|
|
842
|
+
meta = self._get_skill_meta(skill_id, user_id=user_id)
|
|
843
|
+
return meta.get("description") or rules.get("description", "")
|
|
844
|
+
|
|
845
|
+
def format_skills_for_prompt(
|
|
846
|
+
self,
|
|
847
|
+
matched_skills: List[MatchedSkill],
|
|
848
|
+
include_metadata: bool = True
|
|
849
|
+
) -> str:
|
|
850
|
+
"""
|
|
851
|
+
Format matched skills for embedding into system prompt with XML tags.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
matched_skills: List of MatchedSkill objects
|
|
855
|
+
include_metadata: Whether to include match reasons
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
Formatted string with XML-tagged skills ready for prompt embedding
|
|
859
|
+
"""
|
|
860
|
+
if not matched_skills:
|
|
861
|
+
return ""
|
|
862
|
+
|
|
863
|
+
sections = []
|
|
864
|
+
sections.append("<skills>")
|
|
865
|
+
sections.append("These are recommended workflows for this task. Prefer them when "
|
|
866
|
+
"they apply, and use the tools they specify.")
|
|
867
|
+
sections.append("Use judgment: if a step is impossible or a specified tool is "
|
|
868
|
+
"unavailable, adapt and use the best available alternative.")
|
|
869
|
+
sections.append("")
|
|
870
|
+
|
|
871
|
+
for skill in matched_skills:
|
|
872
|
+
# Task 7: warn (never reject) when a matched skill's body is bigger than
|
|
873
|
+
# the recommended injected-body size - the on-disk file already passed
|
|
874
|
+
# the (higher) MAX_SKILL_FILE_CHARS hard reject in validate_skill_content;
|
|
875
|
+
# this is just visibility that a specific skill is bloating the prompt.
|
|
876
|
+
if len(skill.content) > SOFT_INJECT_WARN_CHARS:
|
|
877
|
+
logger.warning(
|
|
878
|
+
"skill '%s' body is %d chars (> %d recommended inject size) - "
|
|
879
|
+
"injecting in full anyway; consider SKILL_PROGRESSIVE_DISCLOSURE "
|
|
880
|
+
"or trimming the skill",
|
|
881
|
+
skill.skill_id, len(skill.content), SOFT_INJECT_WARN_CHARS,
|
|
882
|
+
)
|
|
883
|
+
sections.append(f'<skill id="{skill.skill_id}">')
|
|
884
|
+
|
|
885
|
+
if include_metadata and skill.match_reasons:
|
|
886
|
+
# Group match reasons by type
|
|
887
|
+
keyword_matches = [r for r in skill.match_reasons if r.startswith("keyword:")]
|
|
888
|
+
|
|
889
|
+
if keyword_matches:
|
|
890
|
+
sections.append(f"⚡ Relevant to: {', '.join(r.split(':')[1] for r in keyword_matches[:3])}")
|
|
891
|
+
sections.append("")
|
|
892
|
+
|
|
893
|
+
sections.append(skill.content)
|
|
894
|
+
sections.append("</skill>")
|
|
895
|
+
sections.append("")
|
|
896
|
+
|
|
897
|
+
sections.append("</skills>")
|
|
898
|
+
return "\n".join(sections)
|
|
899
|
+
|
|
900
|
+
def format_skill_catalog(self, matched_skills: List[MatchedSkill]) -> str:
|
|
901
|
+
"""Format matched skills as a compact catalog (S-1 progressive disclosure).
|
|
902
|
+
|
|
903
|
+
Lists only id + one-line description per skill (~20-30 tok each) instead of
|
|
904
|
+
the full bodies. The agent loads a skill's full instructions on demand via
|
|
905
|
+
the load_skill(skill_id) tool. Returns "" when nothing matched.
|
|
906
|
+
"""
|
|
907
|
+
if not matched_skills:
|
|
908
|
+
return ""
|
|
909
|
+
|
|
910
|
+
lines = [
|
|
911
|
+
"<skill-catalog>",
|
|
912
|
+
f"{len(matched_skills)} skill(s) are available for this task. Each is a "
|
|
913
|
+
"detailed workflow you should follow when relevant.",
|
|
914
|
+
"Call load_skill(skill_id=\"<id>\") to load a skill's FULL instructions "
|
|
915
|
+
"BEFORE doing the work it covers. Load only what the current step needs.",
|
|
916
|
+
"",
|
|
917
|
+
]
|
|
918
|
+
for skill in matched_skills:
|
|
919
|
+
desc = (skill.description or skill.skill_id.replace("-", " ")).strip()
|
|
920
|
+
# Keep each line short — one sentence of description at most.
|
|
921
|
+
desc = desc.splitlines()[0][:160] if desc else skill.skill_id
|
|
922
|
+
lines.append(f'- id="{skill.skill_id}" — {desc}')
|
|
923
|
+
lines.append("</skill-catalog>")
|
|
924
|
+
return "\n".join(lines)
|
|
925
|
+
|
|
926
|
+
def get_skill_ids(self) -> List[str]:
|
|
927
|
+
"""Get list of all available skill IDs.
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
List of skill identifiers
|
|
931
|
+
"""
|
|
932
|
+
self._ensure_rules_loaded()
|
|
933
|
+
return list(self.skill_rules.keys())
|
|
934
|
+
|
|
935
|
+
def reload_rules(self) -> None:
|
|
936
|
+
"""Force reload of skill rules from disk."""
|
|
937
|
+
self._rules_loaded = False
|
|
938
|
+
self.skill_cache.clear()
|
|
939
|
+
self.skill_meta_cache.clear()
|
|
940
|
+
self._external_index = None # Task 14: force external re-scan too
|
|
941
|
+
self._ensure_rules_loaded()
|
|
942
|
+
logger.info("Skill rules reloaded")
|
|
943
|
+
|
|
944
|
+
def validate_skill_id(self, skill_id: str) -> Tuple[bool, List[str]]:
|
|
945
|
+
"""Validate skill ID format.
|
|
946
|
+
|
|
947
|
+
Args:
|
|
948
|
+
skill_id: The skill identifier to validate
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Tuple of (is_valid, list of error messages)
|
|
952
|
+
"""
|
|
953
|
+
errors = []
|
|
954
|
+
|
|
955
|
+
if not skill_id:
|
|
956
|
+
errors.append("Skill ID cannot be empty")
|
|
957
|
+
return False, errors
|
|
958
|
+
|
|
959
|
+
if len(skill_id) > MAX_SKILL_ID_LENGTH:
|
|
960
|
+
errors.append(f"Skill ID exceeds max length of {MAX_SKILL_ID_LENGTH}")
|
|
961
|
+
|
|
962
|
+
if not re.match(r'^[a-z][a-z0-9-]*$', skill_id):
|
|
963
|
+
errors.append("Skill ID must be lowercase, start with letter, contain only letters, numbers, hyphens")
|
|
964
|
+
|
|
965
|
+
if '--' in skill_id:
|
|
966
|
+
errors.append("Skill ID cannot contain consecutive hyphens")
|
|
967
|
+
|
|
968
|
+
if skill_id.endswith('-'):
|
|
969
|
+
errors.append("Skill ID cannot end with hyphen")
|
|
970
|
+
|
|
971
|
+
return len(errors) == 0, errors
|
|
972
|
+
|
|
973
|
+
def validate_skill_rules(self, skill_id: str, rules: dict) -> SkillValidationResult:
|
|
974
|
+
"""Validate a skill's rules configuration.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
skill_id: The skill identifier
|
|
978
|
+
rules: The rules dict from rules.json
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
SkillValidationResult with validation details
|
|
982
|
+
"""
|
|
983
|
+
errors = []
|
|
984
|
+
warnings = []
|
|
985
|
+
|
|
986
|
+
# Validate skill ID
|
|
987
|
+
id_valid, id_errors = self.validate_skill_id(skill_id)
|
|
988
|
+
errors.extend(id_errors)
|
|
989
|
+
|
|
990
|
+
# Validate triggers
|
|
991
|
+
triggers = rules.get("triggers", {})
|
|
992
|
+
|
|
993
|
+
# Validate tool_ids
|
|
994
|
+
tool_ids = triggers.get("tool_ids", [])
|
|
995
|
+
for tool_id in tool_ids:
|
|
996
|
+
if tool_id not in VALID_TOOL_IDS:
|
|
997
|
+
warnings.append(f"Unknown tool_id '{tool_id}' - may not trigger correctly")
|
|
998
|
+
|
|
999
|
+
# Validate task_patterns (regex)
|
|
1000
|
+
task_patterns = triggers.get("task_patterns", [])
|
|
1001
|
+
for pattern in task_patterns:
|
|
1002
|
+
try:
|
|
1003
|
+
re.compile(pattern)
|
|
1004
|
+
except re.error as e:
|
|
1005
|
+
errors.append(f"Invalid regex pattern '{pattern}': {e}")
|
|
1006
|
+
|
|
1007
|
+
# Validate priority
|
|
1008
|
+
priority = rules.get("priority", 5)
|
|
1009
|
+
if not isinstance(priority, int) or priority < 1 or priority > 10:
|
|
1010
|
+
warnings.append(f"Priority should be 1-10, got {priority}")
|
|
1011
|
+
|
|
1012
|
+
# Check for empty triggers
|
|
1013
|
+
if not tool_ids and not triggers.get("keywords") and not triggers.get("action_names") and not task_patterns:
|
|
1014
|
+
warnings.append("No triggers defined - skill will never activate")
|
|
1015
|
+
|
|
1016
|
+
return SkillValidationResult(
|
|
1017
|
+
skill_id=skill_id,
|
|
1018
|
+
is_valid=len(errors) == 0,
|
|
1019
|
+
errors=errors,
|
|
1020
|
+
warnings=warnings
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
def validate_skill_content(self, skill_id: str, content: str) -> SkillValidationResult:
|
|
1024
|
+
"""Validate skill content.
|
|
1025
|
+
|
|
1026
|
+
Args:
|
|
1027
|
+
skill_id: The skill identifier
|
|
1028
|
+
content: The skill markdown content
|
|
1029
|
+
|
|
1030
|
+
Returns:
|
|
1031
|
+
SkillValidationResult with validation details
|
|
1032
|
+
"""
|
|
1033
|
+
errors = []
|
|
1034
|
+
warnings = []
|
|
1035
|
+
|
|
1036
|
+
# Check content exists
|
|
1037
|
+
if not content or not content.strip():
|
|
1038
|
+
errors.append("Skill content is empty")
|
|
1039
|
+
return SkillValidationResult(skill_id=skill_id, is_valid=False, errors=errors)
|
|
1040
|
+
|
|
1041
|
+
content_len = len(content)
|
|
1042
|
+
|
|
1043
|
+
# Check minimum length (warning only - MIN_SKILL_CONTENT_CHARS behavior unchanged
|
|
1044
|
+
# by Task 7; short content still loads, it's just noted).
|
|
1045
|
+
if content_len < MIN_SKILL_CONTENT_CHARS:
|
|
1046
|
+
warnings.append(f"Skill content very short ({content_len} chars) - may not be useful")
|
|
1047
|
+
|
|
1048
|
+
# Check maximum length against the on-disk ceiling (Task 7: split cap).
|
|
1049
|
+
# Short content is already handled above as a warning (not a reject), so the
|
|
1050
|
+
# only way this pure helper can still fail here is the "too large" branch.
|
|
1051
|
+
length_ok, _length_msg = validate_skill_content_length(content)
|
|
1052
|
+
if not length_ok and content_len > MAX_SKILL_FILE_CHARS:
|
|
1053
|
+
errors.append(f"Skill content too large ({content_len} chars, max {MAX_SKILL_FILE_CHARS}) - will bloat prompts")
|
|
1054
|
+
|
|
1055
|
+
# Check for markdown structure. Accept an optional YAML frontmatter block
|
|
1056
|
+
# (--- ... ---) at the top for interop with the agentskills.io / clawhub
|
|
1057
|
+
# open skill standard (P1-1); the heading may follow the frontmatter.
|
|
1058
|
+
body = strip_skill_frontmatter(content).strip()
|
|
1059
|
+
if not body.startswith('#'):
|
|
1060
|
+
warnings.append("Skill should start with a markdown heading")
|
|
1061
|
+
|
|
1062
|
+
# Estimate token count (rough: 4 chars per token)
|
|
1063
|
+
estimated_tokens = content_len // 4
|
|
1064
|
+
if estimated_tokens > 2000:
|
|
1065
|
+
warnings.append(f"Skill uses ~{estimated_tokens} tokens - consider trimming")
|
|
1066
|
+
|
|
1067
|
+
return SkillValidationResult(
|
|
1068
|
+
skill_id=skill_id,
|
|
1069
|
+
is_valid=len(errors) == 0,
|
|
1070
|
+
errors=errors,
|
|
1071
|
+
warnings=warnings
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
def validate_skill(self, skill_id: str, user_id: Optional[str] = None) -> SkillValidationResult:
|
|
1075
|
+
"""Validate a complete skill (rules + content).
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
skill_id: The skill identifier
|
|
1079
|
+
user_id: Optional user ID for user skills
|
|
1080
|
+
|
|
1081
|
+
Returns:
|
|
1082
|
+
SkillValidationResult with combined validation details
|
|
1083
|
+
"""
|
|
1084
|
+
self._ensure_rules_loaded()
|
|
1085
|
+
|
|
1086
|
+
all_errors = []
|
|
1087
|
+
all_warnings = []
|
|
1088
|
+
|
|
1089
|
+
# Get rules
|
|
1090
|
+
rules = self.skill_rules.get(skill_id)
|
|
1091
|
+
if not rules:
|
|
1092
|
+
# Check user rules
|
|
1093
|
+
if user_id:
|
|
1094
|
+
user_rules, _ = self._load_user_rules(user_id)
|
|
1095
|
+
rules = user_rules.get(skill_id)
|
|
1096
|
+
|
|
1097
|
+
if not rules:
|
|
1098
|
+
return SkillValidationResult(
|
|
1099
|
+
skill_id=skill_id,
|
|
1100
|
+
is_valid=False,
|
|
1101
|
+
errors=[f"Skill '{skill_id}' not found in rules.json"]
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# Validate rules
|
|
1105
|
+
rules_result = self.validate_skill_rules(skill_id, rules)
|
|
1106
|
+
all_errors.extend(rules_result.errors)
|
|
1107
|
+
all_warnings.extend(rules_result.warnings)
|
|
1108
|
+
|
|
1109
|
+
# Validate content
|
|
1110
|
+
content = self._load_skill_content(skill_id, user_id=user_id)
|
|
1111
|
+
if not content:
|
|
1112
|
+
all_errors.append(f"Skill content file not found for '{skill_id}'")
|
|
1113
|
+
else:
|
|
1114
|
+
content_result = self.validate_skill_content(skill_id, content)
|
|
1115
|
+
all_errors.extend(content_result.errors)
|
|
1116
|
+
all_warnings.extend(content_result.warnings)
|
|
1117
|
+
|
|
1118
|
+
return SkillValidationResult(
|
|
1119
|
+
skill_id=skill_id,
|
|
1120
|
+
is_valid=len(all_errors) == 0,
|
|
1121
|
+
errors=all_errors,
|
|
1122
|
+
warnings=all_warnings
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
def _iter_authored_skill_dirs(self):
|
|
1126
|
+
"""Yield (name, SKILL.md path) for every authored skill directory on disk.
|
|
1127
|
+
|
|
1128
|
+
"Authored" = a directory directly under ``skills_dir`` with a ``SKILL.md``,
|
|
1129
|
+
excluding dotted dirs (``.git`` etc.) and per-user dirs (``user_<id>``,
|
|
1130
|
+
which are runtime-created and not part of the shipped library). Shared by
|
|
1131
|
+
``validate_all_authored`` and ``count_authored_skills`` so the two can never
|
|
1132
|
+
drift on what counts as "a skill".
|
|
1133
|
+
"""
|
|
1134
|
+
for d in self.skills_dir.iterdir():
|
|
1135
|
+
if d.is_dir() and not d.name.startswith((".", "user_")):
|
|
1136
|
+
md = d / "SKILL.md"
|
|
1137
|
+
if md.exists():
|
|
1138
|
+
yield d.name, md
|
|
1139
|
+
|
|
1140
|
+
def validate_all_authored(self) -> Dict[str, list]:
|
|
1141
|
+
"""Strict agentskills.io frontmatter compliance across the shipped skill library.
|
|
1142
|
+
|
|
1143
|
+
Runs :func:`skill_validation.validate_authored` (the strict, "would this
|
|
1144
|
+
pass the upstream skills-ref reference validator" check) against every
|
|
1145
|
+
authored skill's parsed frontmatter. Returns only the skills that have at
|
|
1146
|
+
least one issue — a compliant skill is simply absent from the result — so
|
|
1147
|
+
an empty dict means the whole library is clean.
|
|
1148
|
+
"""
|
|
1149
|
+
from .skill_validation import validate_authored
|
|
1150
|
+
out: Dict[str, list] = {}
|
|
1151
|
+
for name, md in self._iter_authored_skill_dirs():
|
|
1152
|
+
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
|
|
1153
|
+
issues = validate_authored(meta, name)
|
|
1154
|
+
if issues:
|
|
1155
|
+
out[name] = issues
|
|
1156
|
+
return out
|
|
1157
|
+
|
|
1158
|
+
def count_authored_skills(self) -> int:
|
|
1159
|
+
"""Total number of authored (non-dotted, non-``user_``) skill directories on disk."""
|
|
1160
|
+
return sum(1 for _ in self._iter_authored_skill_dirs())
|
|
1161
|
+
|
|
1162
|
+
def provenance_of(self, skill_id: str, user_id: str) -> Optional[str]:
|
|
1163
|
+
"""Return the recorded ``created_by`` for a skill — LOCAL-ONLY trust source.
|
|
1164
|
+
|
|
1165
|
+
Reads exclusively from ``skill_usage.db``'s ``skill_provenance`` table
|
|
1166
|
+
(``SkillUsageStore.get_provenance``, populated at write time by
|
|
1167
|
+
``SkillWriterMixin._record_provenance`` from the ``create_skill``/
|
|
1168
|
+
``patch_skill``/``delete_skill`` **argument**). This is deliberately NEVER
|
|
1169
|
+
derived from the skill's own SKILL.md frontmatter: an imported/external
|
|
1170
|
+
skill could ship a forged ``metadata: {polyrob-created-by: user}`` block
|
|
1171
|
+
to claim trusted ("user") origin and slip past the leaf/background ->
|
|
1172
|
+
``.pending`` quarantine gate (see ``_resolve_pending``/``_NON_USER_AUTHORS``
|
|
1173
|
+
in ``skill_writer.py``). ``skill_writer.py`` doesn't even import a
|
|
1174
|
+
frontmatter parser, so this can't regress silently — see
|
|
1175
|
+
``tests/unit/agents/task/test_skill_provenance_local.py``.
|
|
1176
|
+
|
|
1177
|
+
Returns ``None`` if unknown / never recorded / anonymous ``user_id``.
|
|
1178
|
+
"""
|
|
1179
|
+
try:
|
|
1180
|
+
from modules.skills.skill_usage import get_skill_usage_store
|
|
1181
|
+
row = get_skill_usage_store().get_provenance(skill_id, user_id)
|
|
1182
|
+
except Exception:
|
|
1183
|
+
logger.debug("provenance_of lookup failed for %s/%s", user_id, skill_id, exc_info=True)
|
|
1184
|
+
return None
|
|
1185
|
+
return row.get("created_by") if row else None
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
# Module-level singleton for convenience
|
|
1189
|
+
_skill_manager: Optional[SkillManager] = None
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def get_skill_manager() -> SkillManager:
|
|
1193
|
+
"""Get the singleton SkillManager instance.
|
|
1194
|
+
|
|
1195
|
+
Returns:
|
|
1196
|
+
SkillManager instance
|
|
1197
|
+
"""
|
|
1198
|
+
global _skill_manager
|
|
1199
|
+
if _skill_manager is None:
|
|
1200
|
+
_skill_manager = SkillManager()
|
|
1201
|
+
return _skill_manager
|