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.
Files changed (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. 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