alysis-code 0.13.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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,624 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import json
5
+ import os
6
+ import re
7
+ import socket
8
+ import threading
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .config import AppConfig, default_sessions_dir
16
+ from .logging_redaction import redact_log_text
17
+ from .session_artifacts import SessionArtifactLayout
18
+ from .web_research import SessionWebResearchTracker
19
+
20
+
21
+ def _now_ts() -> str:
22
+ return datetime.now(UTC).isoformat()
23
+
24
+
25
+ def make_session_id() -> str:
26
+ ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
27
+ # 8 chars is enough for local uniqueness.
28
+ import uuid
29
+
30
+ return f"{ts}_{uuid.uuid4().hex[:8]}"
31
+
32
+
33
+ def sanitize_session_id(session_id: str) -> str:
34
+ return re.sub(r"[^A-Za-z0-9_-]", "_", session_id.strip()) or "session"
35
+
36
+
37
+ def resolve_sessions_dir(cfg: AppConfig) -> Path:
38
+ if cfg.session_log_dir:
39
+ return Path(cfg.session_log_dir)
40
+ return default_sessions_dir()
41
+
42
+
43
+ def local_session_owner() -> str | None:
44
+ """Deterministic identity (``os-user@hostname``) of the local account.
45
+
46
+ Session logs are stamped with their creator's identity so listing surfaces
47
+ (``/resume``, "latest session" defaults) can hide conversations that were
48
+ not created by this account — regardless of how a foreign log file arrived
49
+ in the sessions directory (copied archive, baked disk image, shared host
50
+ with distinct accounts). Purely local and deterministic: no network, no
51
+ model/provider involvement. Returns ``None`` when neither component can be
52
+ resolved; callers must treat that as "no local identity established".
53
+ """
54
+ try:
55
+ user = getpass.getuser().strip()
56
+ except (KeyError, OSError, ImportError):
57
+ # No passwd entry / no LOGNAME-USER-USERNAME env; on py<=3.12 the
58
+ # env-less fallback is a bare ``import pwd`` which raises
59
+ # ModuleNotFoundError on Windows-family hosts (3.13+ maps it to
60
+ # OSError) — this function must degrade to None, never crash startup.
61
+ user = ""
62
+ try:
63
+ # Use the direct socket API instead of ``platform.node()``. On Windows,
64
+ # ``platform.node()`` can invoke ``ver`` through a shell, which makes a
65
+ # harmless identity lookup observable as an unrelated subprocess and
66
+ # lets hook subprocess instrumentation intercept it.
67
+ host = socket.gethostname().strip()
68
+ except OSError:
69
+ host = ""
70
+ if not user and not host:
71
+ return None
72
+ return f"{user}@{host}"
73
+
74
+
75
+ @dataclass
76
+ class SessionInfo:
77
+ session_id: str
78
+ path: Path
79
+ mtime: float
80
+ # Log-derived scoping/recency metadata (all optional so any existing
81
+ # ``SessionInfo(session_id=..., path=..., mtime=...)`` construction keeps
82
+ # working). ``workspace_root``/``git_root`` come from the session log's own
83
+ # events (not the filesystem) and let ``/resume`` show only the current
84
+ # workspace's chats. ``last_event_ts`` is the log's own UTC ISO timestamp of
85
+ # the most recent event, used for the displayed "when" instead of file mtime
86
+ # (which drifts after copies/extracts).
87
+ # ``owner`` is the log-recorded creator identity (see
88
+ # :func:`local_session_owner`); listings hide sessions stamped by a
89
+ # different account so one user's conversations never surface for another.
90
+ # ``last_owner`` is the identity stamped on the log's newest event — the
91
+ # account that most recently used the session. Matching either lets an
92
+ # explicit ``/resume <id>`` re-adopt a session whose recorded identity
93
+ # drifted (e.g. after a hostname rename): the resume appends fresh events
94
+ # with the current identity, so the session self-heals into the listing.
95
+ workspace_root: str | None = None
96
+ git_root: str | None = None
97
+ last_event_ts: str | None = None
98
+ owner: str | None = None
99
+ last_owner: str | None = None
100
+
101
+
102
+ class SessionStore:
103
+ def __init__(
104
+ self,
105
+ *,
106
+ enabled: bool,
107
+ artifact_persistence_enabled: bool | None = None,
108
+ sessions_dir: Path,
109
+ session_id: str,
110
+ cwd: str,
111
+ repo_root: str | None,
112
+ workspace_root: str | None = None,
113
+ focus_dir: str | None = None,
114
+ git_root: str | None = None,
115
+ workspace_kind: str | None = None,
116
+ binding_source: str | None = None,
117
+ binding_requested_path: str | None = None,
118
+ binding_risk_level: str | None = None,
119
+ binding_created_path: bool | None = None,
120
+ runtime_kind: str | None = None,
121
+ active_workdir: str | None = None,
122
+ active_workdir_relpath: str | None = None,
123
+ owner: str | None = None,
124
+ ) -> None:
125
+ self.enabled = enabled
126
+ self.sessions_dir = sessions_dir
127
+ self.session_id = session_id
128
+ self.cwd = cwd
129
+ self.repo_root = repo_root
130
+ self.workspace_root = workspace_root
131
+ # Stamp every log with its creator so listings can scope per account.
132
+ self.owner = owner if owner is not None else local_session_owner()
133
+ self.focus_dir = focus_dir
134
+ self.git_root = git_root
135
+ self.workspace_kind = workspace_kind
136
+ self.binding_source = binding_source
137
+ self.binding_requested_path = binding_requested_path
138
+ self.binding_risk_level = binding_risk_level
139
+ self.binding_created_path = binding_created_path
140
+ self.runtime_kind = runtime_kind
141
+ self.active_workdir = active_workdir or cwd
142
+ self.active_workdir_relpath = active_workdir_relpath
143
+ self.path = sessions_dir / f"{session_id}.jsonl"
144
+ self._lock = threading.RLock()
145
+ self.artifact_persistence_enabled = (
146
+ enabled if artifact_persistence_enabled is None else bool(artifact_persistence_enabled)
147
+ )
148
+ self._events: list[dict[str, Any]] = []
149
+ self._web_research = SessionWebResearchTracker()
150
+ self._hydrate_existing_state()
151
+
152
+ self._fh = None
153
+ if self.enabled:
154
+ try:
155
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
156
+ self._fh = self.path.open("a", encoding="utf-8")
157
+ except OSError:
158
+ self.enabled = False
159
+ self.artifact_persistence_enabled = False
160
+
161
+ @property
162
+ def session_artifact_root(self) -> Path:
163
+ return self.sessions_dir / sanitize_session_id(self.session_id)
164
+
165
+ @property
166
+ def session_artifact_layout(self) -> SessionArtifactLayout:
167
+ return SessionArtifactLayout(filesystem_root=self.session_artifact_root)
168
+
169
+ def runtime_artifact_path(self, *parts: str) -> Path:
170
+ return self.session_artifact_layout.artifact_fs_path(*parts)
171
+
172
+ def append(
173
+ self,
174
+ event_type: str,
175
+ payload: dict[str, Any],
176
+ *,
177
+ observation_payload: dict[str, Any] | None = None,
178
+ ) -> None:
179
+ event = self._build_event(event_type=event_type, payload=payload)
180
+ with self._lock:
181
+ event["event_id"] = f"{self.session_id}:{len(self._events) + 1}"
182
+ self._events.append(event)
183
+ observed_web_research = self._web_research.observe_event(
184
+ event_type=event_type,
185
+ payload=observation_payload if observation_payload is not None else payload,
186
+ ts=str(event.get("ts") or "").strip() or None,
187
+ event_id=str(event.get("event_id") or "").strip() or None,
188
+ )
189
+ if self.enabled and self._fh:
190
+ self._fh.write(redact_log_text(json.dumps(event, ensure_ascii=True) + "\n"))
191
+ self._fh.flush()
192
+ if observed_web_research and self.artifact_persistence_enabled:
193
+ self._persist_web_research_artifact()
194
+
195
+ def _build_event(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
196
+ event = {
197
+ "type": event_type,
198
+ "ts": _now_ts(),
199
+ "session_id": self.session_id,
200
+ "cwd": self.cwd,
201
+ "repo_root": self.repo_root,
202
+ "pid": os.getpid(),
203
+ "payload": payload,
204
+ }
205
+ if self.workspace_root is not None:
206
+ event["workspace_root"] = self.workspace_root
207
+ if self.owner is not None:
208
+ event["owner"] = self.owner
209
+ if self.focus_dir is not None:
210
+ event["focus_dir"] = self.focus_dir
211
+ if self.git_root is not None:
212
+ event["git_root"] = self.git_root
213
+ if self.workspace_kind is not None:
214
+ event["workspace_kind"] = self.workspace_kind
215
+ if self.binding_source is not None:
216
+ event["binding_source"] = self.binding_source
217
+ if self.binding_requested_path is not None:
218
+ event["binding_requested_path"] = self.binding_requested_path
219
+ if self.binding_risk_level is not None:
220
+ event["binding_risk_level"] = self.binding_risk_level
221
+ if self.binding_created_path is not None:
222
+ event["binding_created_path"] = self.binding_created_path
223
+ if self.runtime_kind is not None:
224
+ event["runtime_kind"] = self.runtime_kind
225
+ if self.active_workdir is not None:
226
+ event["active_workdir"] = self.active_workdir
227
+ if self.active_workdir_relpath is not None:
228
+ event["active_workdir_relpath"] = self.active_workdir_relpath
229
+ return event
230
+
231
+ def update_active_workdir(self, *, cwd: str, active_workdir_relpath: str) -> None:
232
+ self.cwd = cwd
233
+ self.active_workdir = cwd
234
+ self.active_workdir_relpath = active_workdir_relpath
235
+
236
+ def append_artifact_jsonl(self, *parts: str, payload: dict[str, Any]) -> Path | None:
237
+ if not self.enabled:
238
+ return None
239
+ path = self.runtime_artifact_path(*parts)
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ with path.open("a", encoding="utf-8") as fh:
242
+ fh.write(redact_log_text(json.dumps(payload, ensure_ascii=True) + "\n"))
243
+ return path
244
+
245
+ def events_snapshot(self) -> list[dict[str, Any]]:
246
+ with self._lock:
247
+ return json.loads(json.dumps(self._events, ensure_ascii=True))
248
+
249
+ def events_since(self, cursor: int) -> tuple[list[dict[str, Any]], int]:
250
+ """Return events appended at or after ``cursor`` and the next cursor.
251
+
252
+ Events are append-only and their positional order is protected by the
253
+ store lock. A slice is therefore enough for incremental consumers; in
254
+ particular, this avoids the full JSON round-trip used by
255
+ :meth:`events_snapshot`.
256
+ """
257
+ with self._lock:
258
+ start = max(0, int(cursor))
259
+ next_cursor = len(self._events)
260
+ return self._events[start:], next_cursor
261
+
262
+ def classify_web_fetch_url(self, raw_url: Any) -> str | None:
263
+ return self._web_research.classify_fetch_url(raw_url)
264
+
265
+ def resolve_web_fetch_url(self, raw_url: Any) -> tuple[str | None, str | None]:
266
+ return self._web_research.resolve_fetch_url(raw_url)
267
+
268
+ def fetchable_web_fetch_urls(self, *, limit: int = 10) -> list[str]:
269
+ return self._web_research.fetchable_urls(limit=limit)
270
+
271
+ def establish_search_mediated_web_fetch_url(
272
+ self,
273
+ *,
274
+ raw_url: Any,
275
+ query: str,
276
+ source_url: str | None = None,
277
+ ) -> tuple[bool, str | None]:
278
+ with self._lock:
279
+ changed, normalized = self._web_research.establish_search_mediated_fetch_url(
280
+ raw_url=raw_url,
281
+ query=query,
282
+ source_url=source_url,
283
+ )
284
+ if changed and self.artifact_persistence_enabled:
285
+ self._persist_web_research_artifact()
286
+ return changed, normalized
287
+
288
+ def web_research_artifact_payload(self) -> dict[str, Any]:
289
+ return self._web_research.artifact_payload()
290
+
291
+ def web_research_metrics_payload(self) -> dict[str, int]:
292
+ return self._web_research.metrics_payload()
293
+
294
+ def has_web_research_activity(self) -> bool:
295
+ return self._web_research.has_activity()
296
+
297
+ def _persist_web_research_artifact(self) -> Path | None:
298
+ if not self.artifact_persistence_enabled or not self._web_research.has_activity():
299
+ return None
300
+ path = self.runtime_artifact_path("web_research_sources.json")
301
+ path.parent.mkdir(parents=True, exist_ok=True)
302
+ path.write_text(
303
+ redact_log_text(
304
+ json.dumps(
305
+ self._web_research.artifact_payload(),
306
+ indent=2,
307
+ sort_keys=True,
308
+ ensure_ascii=True,
309
+ )
310
+ + "\n"
311
+ ),
312
+ encoding="utf-8",
313
+ )
314
+ return path
315
+
316
+ def _hydrate_existing_state(self) -> None:
317
+ self._hydrate_from_existing_log()
318
+ self._hydrate_from_existing_web_research_artifact()
319
+ self._web_research.clear_pending()
320
+
321
+ def _hydrate_from_existing_log(self) -> bool:
322
+ try:
323
+ if not self.path.exists():
324
+ return False
325
+ events = list(read_session_events(self.path))
326
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
327
+ return False
328
+ if not events:
329
+ return False
330
+ self._events = json.loads(json.dumps(events, ensure_ascii=True))
331
+ for index, event in enumerate(self._events, start=1):
332
+ payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
333
+ self._web_research.observe_event(
334
+ event_type=str(event.get("type") or "").strip(),
335
+ payload=payload,
336
+ ts=str(event.get("ts") or "").strip() or None,
337
+ event_id=str(event.get("event_id") or "").strip() or f"{self.session_id}:{index}",
338
+ )
339
+ return True
340
+
341
+ def _hydrate_from_existing_web_research_artifact(self) -> bool:
342
+ artifact_path = self.runtime_artifact_path("web_research_sources.json")
343
+ try:
344
+ if not artifact_path.exists():
345
+ return False
346
+ payload = json.loads(artifact_path.read_text(encoding="utf-8"))
347
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
348
+ return False
349
+ return self._web_research.merge_from_artifact_payload(payload)
350
+
351
+ def close(self) -> None:
352
+ if self.artifact_persistence_enabled and self._web_research.has_activity():
353
+ self._persist_web_research_artifact()
354
+ if self._fh:
355
+ self._fh.close()
356
+ self._fh = None
357
+
358
+
359
+ def _nonempty_str(value: Any) -> str | None:
360
+ if isinstance(value, str):
361
+ text = value.strip()
362
+ if text:
363
+ return text
364
+ return None
365
+
366
+
367
+ def canonical_workspace_path(raw: str | Path | None) -> Path | None:
368
+ """Canonicalize a workspace/git-root path for identity comparison.
369
+
370
+ Uses ``Path.resolve()`` so trailing slashes, ``.``/``..`` segments, and the
371
+ OS's own case rules are absorbed (case-insensitive on Windows via the
372
+ resolved canonical casing, case-sensitive on POSIX) without any per-platform
373
+ special-casing. Returns ``None`` for empty/unresolvable input. This is
374
+ deterministic and provider/model-agnostic by construction.
375
+ """
376
+ text = _nonempty_str(raw if isinstance(raw, str) else (str(raw) if raw is not None else None))
377
+ if text is None:
378
+ return None
379
+ try:
380
+ return Path(text).expanduser().resolve()
381
+ except (OSError, RuntimeError, ValueError):
382
+ return None
383
+
384
+
385
+ def session_belongs_to_workspace(
386
+ info: SessionInfo,
387
+ current_workspace_root: Path | None,
388
+ current_git_root: Path | None = None,
389
+ ) -> bool:
390
+ """Return True if ``info`` (a session log) belongs to the current workspace.
391
+
392
+ Primary identity is the log's recorded ``workspace_root``. When that is
393
+ absent (a legacy log predating workspace_root recording) we fall back to
394
+ ``git_root``. When neither yields a deterministic match we return False:
395
+ such strays are hidden from the scoped picker but remain reachable by an
396
+ explicit ``/resume <id>``. ``current_workspace_root``/``current_git_root``
397
+ must already be canonicalized via :func:`canonical_workspace_path`.
398
+ """
399
+ if info.workspace_root is not None:
400
+ if current_workspace_root is None:
401
+ return False
402
+ session_ws = canonical_workspace_path(info.workspace_root)
403
+ return session_ws is not None and session_ws == current_workspace_root
404
+ if info.git_root is not None and current_git_root is not None:
405
+ session_git = canonical_workspace_path(info.git_root)
406
+ return session_git is not None and session_git == current_git_root
407
+ return False
408
+
409
+
410
+ def session_belongs_to_owner(info: SessionInfo, current_owner: str | None) -> bool:
411
+ """Return True if ``info`` (a session log) may be listed for this account.
412
+
413
+ A log stamped with an owner is only listed when the current identity
414
+ matches either the creator stamp (``owner``, from the log's first events)
415
+ or the newest event's stamp (``last_owner``) — compared case-insensitively,
416
+ since Windows usernames and DNS hostnames are not case-significant. The
417
+ ``last_owner`` leg is the self-heal path: when a user's identity drifts
418
+ (hostname rename, WSL vs native), one explicit ``/resume <id>`` appends
419
+ fresh events under the new identity and the session lists again. A foreign
420
+ log stays hidden either way — both its stamps are foreign.
421
+
422
+ Legacy logs with no recorded owner on either end stay visible so an
423
+ upgrade never empties a user's own list (workspace scoping still applies
424
+ to them). When the log records an owner but the local identity cannot be
425
+ established, the log is hidden: a foreign-stamped conversation must never
426
+ surface on an unidentifiable account. Hidden logs remain reachable by an
427
+ explicit ``/resume <id>`` on the machine that holds them.
428
+ """
429
+ recorded_first = _nonempty_str(info.owner)
430
+ recorded_last = _nonempty_str(info.last_owner)
431
+ if recorded_first is None and recorded_last is None:
432
+ return True
433
+ current = _nonempty_str(current_owner)
434
+ if current is None:
435
+ return False
436
+ current_folded = current.casefold()
437
+ if recorded_first is not None and recorded_first.casefold() == current_folded:
438
+ return True
439
+ return recorded_last is not None and recorded_last.casefold() == current_folded
440
+
441
+
442
+ def filter_sessions_to_local_owner(infos: list[SessionInfo]) -> list[SessionInfo]:
443
+ """Drop sessions stamped by a different account than the local one."""
444
+ current_owner = local_session_owner()
445
+ return [info for info in infos if session_belongs_to_owner(info, current_owner)]
446
+
447
+
448
+ def read_session_first_event_workspace(path: Path) -> tuple[str | None, str | None]:
449
+ """Read the session log's recorded (workspace_root, git_root)."""
450
+ workspace_root, git_root, _owner = read_session_first_event_scope(path)
451
+ return workspace_root, git_root
452
+
453
+
454
+ def read_session_first_event_scope(path: Path) -> tuple[str | None, str | None, str | None]:
455
+ """Read the log's recorded (workspace_root, git_root, owner).
456
+
457
+ Every event carries these at top level (see :meth:`SessionStore._build_event`)
458
+ and the ``session_start`` payload also records the workspace fields; we scan
459
+ the first few events and return the first non-empty values found. Bounded so
460
+ we never read a whole (possibly large) log. Any read/parse error yields
461
+ ``(None, None, None)``.
462
+ """
463
+ workspace_root: str | None = None
464
+ git_root: str | None = None
465
+ owner: str | None = None
466
+ events = read_session_events(path)
467
+ try:
468
+ checked = 0
469
+ for event in events:
470
+ if not isinstance(event, dict):
471
+ continue
472
+ checked += 1
473
+ if workspace_root is None:
474
+ workspace_root = _nonempty_str(event.get("workspace_root"))
475
+ if git_root is None:
476
+ git_root = _nonempty_str(event.get("git_root"))
477
+ if owner is None:
478
+ owner = _nonempty_str(event.get("owner"))
479
+ payload = event.get("payload")
480
+ if isinstance(payload, dict):
481
+ if workspace_root is None:
482
+ workspace_root = _nonempty_str(payload.get("workspace_root"))
483
+ if git_root is None:
484
+ git_root = _nonempty_str(payload.get("git_root"))
485
+ if owner is None:
486
+ owner = _nonempty_str(payload.get("owner"))
487
+ # Keep scanning (within the bound) until the owner stamp is found
488
+ # too: a pre-upgrade log resumed post-upgrade carries its owner
489
+ # only on later events, and classifying it as legacy would leave
490
+ # it visible to every account.
491
+ if workspace_root is not None and git_root is not None and owner is not None:
492
+ break
493
+ if checked >= 5:
494
+ break
495
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
496
+ return None, None, None
497
+ finally:
498
+ close = getattr(events, "close", None)
499
+ if callable(close):
500
+ close()
501
+ return workspace_root, git_root, owner
502
+
503
+
504
+ def read_session_last_event_ts(path: Path) -> str | None:
505
+ """Return the UTC ISO ``ts`` of the log's most recent event."""
506
+ ts, _owner = read_session_last_event_fields(path)
507
+ return ts
508
+
509
+
510
+ def read_session_last_event_fields(path: Path) -> tuple[str | None, str | None]:
511
+ """Return the (``ts``, ``owner``) recorded at the tail of the log.
512
+
513
+ Tail-scans the last chunk of the file (events are append-only and flushed,
514
+ so the final parseable line is the newest event) rather than parsing the
515
+ whole log. ``ts`` comes from the newest event that carries one; ``owner``
516
+ comes from the newest parseable event — the account that most recently
517
+ used the session. Returns ``(None, None)`` on empty/corrupt/unreadable
518
+ logs so callers fall back to file mtime / creator-stamp semantics.
519
+ """
520
+ try:
521
+ with path.open("rb") as fh:
522
+ fh.seek(0, os.SEEK_END)
523
+ file_size = fh.tell()
524
+ if file_size <= 0:
525
+ return None, None
526
+ read_size = min(file_size, 16384)
527
+ fh.seek(file_size - read_size)
528
+ data = fh.read()
529
+ except OSError:
530
+ return None, None
531
+ owner: str | None = None
532
+ seen_event = False
533
+ for raw_line in reversed(data.split(b"\n")):
534
+ stripped = raw_line.strip()
535
+ if not stripped:
536
+ continue
537
+ try:
538
+ obj = json.loads(stripped)
539
+ except (json.JSONDecodeError, UnicodeDecodeError):
540
+ continue
541
+ if isinstance(obj, dict):
542
+ if not seen_event:
543
+ owner = _nonempty_str(obj.get("owner"))
544
+ seen_event = True
545
+ ts = _nonempty_str(obj.get("ts"))
546
+ if ts is not None:
547
+ return ts, owner
548
+ return None, owner
549
+
550
+
551
+ def _epoch_from_iso(raw: str | None) -> float | None:
552
+ text = _nonempty_str(raw)
553
+ if text is None:
554
+ return None
555
+ if text.endswith("Z"):
556
+ text = text[:-1] + "+00:00"
557
+ try:
558
+ parsed = datetime.fromisoformat(text)
559
+ except ValueError:
560
+ return None
561
+ try:
562
+ return parsed.timestamp()
563
+ except (OSError, OverflowError, ValueError):
564
+ return None
565
+
566
+
567
+ def _session_recency_sort_key(info: SessionInfo) -> float:
568
+ # Prefer the log's own last-event timestamp (immune to copy/extract mtime
569
+ # resets); fall back to filesystem mtime. Both are absolute epoch seconds,
570
+ # so they order correctly even when mixed.
571
+ epoch = _epoch_from_iso(info.last_event_ts)
572
+ if epoch is not None:
573
+ return epoch
574
+ try:
575
+ return float(info.mtime)
576
+ except (TypeError, ValueError):
577
+ return 0.0
578
+
579
+
580
+ def list_sessions(sessions_dir: Path) -> list[SessionInfo]:
581
+ if not sessions_dir.exists():
582
+ return []
583
+ out: list[SessionInfo] = []
584
+ for p in sessions_dir.glob("*.jsonl"):
585
+ try:
586
+ st = p.stat()
587
+ except OSError:
588
+ continue
589
+ try:
590
+ workspace_root, git_root, owner = read_session_first_event_scope(p)
591
+ except OSError:
592
+ workspace_root, git_root, owner = None, None, None
593
+ try:
594
+ last_event_ts, last_owner = read_session_last_event_fields(p)
595
+ except OSError:
596
+ last_event_ts, last_owner = None, None
597
+ out.append(
598
+ SessionInfo(
599
+ session_id=p.stem,
600
+ path=p,
601
+ mtime=st.st_mtime,
602
+ workspace_root=workspace_root,
603
+ git_root=git_root,
604
+ last_event_ts=last_event_ts,
605
+ owner=owner,
606
+ last_owner=last_owner,
607
+ )
608
+ )
609
+ out.sort(key=_session_recency_sort_key, reverse=True)
610
+ return out
611
+
612
+
613
+ def read_session_events(path: Path) -> Iterable[dict[str, Any]]:
614
+ with path.open(encoding="utf-8") as fh:
615
+ for line in fh:
616
+ line = line.strip()
617
+ if not line:
618
+ continue
619
+ try:
620
+ obj = json.loads(line)
621
+ except json.JSONDecodeError:
622
+ continue
623
+ if isinstance(obj, dict):
624
+ yield obj
@@ -0,0 +1,34 @@
1
+ # Skills
2
+
3
+ This package implements Alysis Code skills: reusable instruction bundles rooted
4
+ at a `SKILL.md` file.
5
+
6
+ ## Contents
7
+
8
+ - `discovery.py` finds skills in approved project and user roots.
9
+ - `loader.py`, `models.py`, and `validation.py` parse and validate bundles.
10
+ - `scaffold.py`, `install.py`, `state.py`, and `transactions.py` support the
11
+ local skill lifecycle.
12
+ - `prompting.py` prepares concise skill context for sessions.
13
+ - `matching.py` and `evals.py` support deterministic matching and evaluation.
14
+ - `conventions.py` handles repo convention files, which are separate from
15
+ skills.
16
+
17
+ ## Scope
18
+
19
+ Skills are instruction text, not host policy or executable capability. They
20
+ should be advertised compactly and read on demand rather than copied wholesale
21
+ into every prompt.
22
+
23
+ Repo convention files such as `AGENTS.md`, `CLAUDE.md`, and `CONVENTIONS.md`
24
+ must remain separate from the skill registry.
25
+
26
+ ## Development
27
+
28
+ Changes to discovery, validation, or prompt rendering should include regression
29
+ coverage for both project-local and user-global skill roots.
30
+
31
+ ## See Also
32
+
33
+ - [Skills](../../../docs/skills.md)
34
+ - [Skills lifecycle](../../../docs/skills_lifecycle.md)