gemcode 0.4.22__tar.gz → 0.4.24__tar.gz

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 (209) hide show
  1. {gemcode-0.4.22/src/gemcode.egg-info → gemcode-0.4.24}/PKG-INFO +1 -1
  2. {gemcode-0.4.22 → gemcode-0.4.24}/pyproject.toml +1 -1
  3. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/agent_habits.py +13 -1
  4. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/agent_mesh.py +48 -0
  5. gemcode-0.4.24/src/gemcode/habit_chains.py +219 -0
  6. gemcode-0.4.24/src/gemcode/habit_runs.py +137 -0
  7. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/invoke.py +9 -6
  8. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/kaira_daemon.py +8 -6
  9. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/trust.py +45 -2
  10. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/fleet_api.py +126 -7
  11. gemcode-0.4.24/src/gemcode/web/preview_api.py +213 -0
  12. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/server.py +71 -4
  13. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/sse_adapter.py +22 -8
  14. gemcode-0.4.24/src/gemcode/web/ui_chat_store.py +76 -0
  15. {gemcode-0.4.22 → gemcode-0.4.24/src/gemcode.egg-info}/PKG-INFO +1 -1
  16. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode.egg-info/SOURCES.txt +7 -0
  17. gemcode-0.4.24/tests/test_habit_chains.py +57 -0
  18. gemcode-0.4.24/tests/test_habit_runs.py +58 -0
  19. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_hosted_tenant.py +14 -0
  20. gemcode-0.4.24/tests/test_preview_proxy.py +21 -0
  21. gemcode-0.4.24/tests/test_sse_confirmation_batch.py +31 -0
  22. gemcode-0.4.22/src/gemcode/web/preview_api.py +0 -82
  23. {gemcode-0.4.22 → gemcode-0.4.24}/LICENSE +0 -0
  24. {gemcode-0.4.22 → gemcode-0.4.24}/MANIFEST.in +0 -0
  25. {gemcode-0.4.22 → gemcode-0.4.24}/README.md +0 -0
  26. {gemcode-0.4.22 → gemcode-0.4.24}/setup.cfg +0 -0
  27. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/__init__.py +0 -0
  28. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/__main__.py +0 -0
  29. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/a2a_bridge.py +0 -0
  30. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/agent.py +0 -0
  31. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/agent_intelligence.py +0 -0
  32. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/agent_triggers.py +0 -0
  33. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/audit.py +0 -0
  34. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/autocompact.py +0 -0
  35. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/automations.py +0 -0
  36. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/autotune.py +0 -0
  37. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/callbacks.py +0 -0
  38. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/capability_routing.py +0 -0
  39. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/checkpoints.py +0 -0
  40. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/cli.py +0 -0
  41. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/codebase_awareness.py +0 -0
  42. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/compaction.py +0 -0
  43. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/computer_use/__init__.py +0 -0
  44. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/computer_use/browser_computer.py +0 -0
  45. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/config.py +0 -0
  46. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/context_budget.py +0 -0
  47. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/context_warning.py +0 -0
  48. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/credentials.py +0 -0
  49. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/curated_memory.py +0 -0
  50. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/delegation_learning.py +0 -0
  51. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/dynamic_policy.py +0 -0
  52. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/evals/harness.py +0 -0
  53. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/event_bus.py +0 -0
  54. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/fleet_reports.py +0 -0
  55. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/hitl_session.py +0 -0
  56. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/hooks.py +0 -0
  57. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/ide_protocol.py +0 -0
  58. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/ide_stdio.py +0 -0
  59. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/intent_classifier.py +0 -0
  60. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/interactions.py +0 -0
  61. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/kaira_client.py +0 -0
  62. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/kaira_ipc.py +0 -0
  63. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/kaira_job_store.py +0 -0
  64. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/learning.py +0 -0
  65. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/limits.py +0 -0
  66. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/live_audio_engine.py +0 -0
  67. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/logging_config.py +0 -0
  68. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/mcp_loader.py +0 -0
  69. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/memory/__init__.py +0 -0
  70. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/memory/embedding_memory_service.py +0 -0
  71. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/memory/file_memory_service.py +0 -0
  72. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/modality_tools.py +0 -0
  73. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/model_errors.py +0 -0
  74. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/model_routing.py +0 -0
  75. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/multimodal_input.py +0 -0
  76. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/openapi_loader.py +0 -0
  77. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/org.py +0 -0
  78. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/output_styles.py +0 -0
  79. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/paths.py +0 -0
  80. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/permissions.py +0 -0
  81. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/plugins/__init__.py +0 -0
  82. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  83. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  84. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/policy_profile.py +0 -0
  85. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/pricing.py +0 -0
  86. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/prompt_suggestions.py +0 -0
  87. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/__init__.py +0 -0
  88. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/config.py +0 -0
  89. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/deps.py +0 -0
  90. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/engine.py +0 -0
  91. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/stop_hooks.py +0 -0
  92. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/token_budget.py +0 -0
  93. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query/transitions.py +0 -0
  94. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/query_sanitizer.py +0 -0
  95. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/refine.py +0 -0
  96. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/repl_commands.py +0 -0
  97. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/repl_slash.py +0 -0
  98. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/review_agent.py +0 -0
  99. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/rules.py +0 -0
  100. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/self_healing.py +0 -0
  101. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/session_runtime.py +0 -0
  102. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/session_store.py +0 -0
  103. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/session_summariser.py +0 -0
  104. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/skills.py +0 -0
  105. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/slash_commands.py +0 -0
  106. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/thinking.py +0 -0
  107. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tool_prompt_manifest.py +0 -0
  108. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tool_registry.py +0 -0
  109. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tool_result_store.py +0 -0
  110. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tool_synthesis.py +0 -0
  111. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/__init__.py +0 -0
  112. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/automations_tools.py +0 -0
  113. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/bash.py +0 -0
  114. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/browser.py +0 -0
  115. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/compress_memory.py +0 -0
  116. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/curated_memory.py +0 -0
  117. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/edit.py +0 -0
  118. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/filesystem.py +0 -0
  119. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/notebook.py +0 -0
  120. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/notes.py +0 -0
  121. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/org_tools.py +0 -0
  122. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/repo_map.py +0 -0
  123. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/search.py +0 -0
  124. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/shell.py +0 -0
  125. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/shell_gate.py +0 -0
  126. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/skills.py +0 -0
  127. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/subtask.py +0 -0
  128. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/tasks.py +0 -0
  129. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/think.py +0 -0
  130. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/todo.py +0 -0
  131. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/user_choice.py +0 -0
  132. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/veomem_tools.py +0 -0
  133. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/web.py +0 -0
  134. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools/web_search.py +0 -0
  135. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tools_inspector.py +0 -0
  136. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tui/input_handler.py +0 -0
  137. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tui/scrollback.py +0 -0
  138. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tui/spinner.py +0 -0
  139. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tui/welcome_banner.py +0 -0
  140. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/tui/welcome_rich.py +0 -0
  141. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/veomem_bridge.py +0 -0
  142. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/version.py +0 -0
  143. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/vertex.py +0 -0
  144. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/wal.py +0 -0
  145. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/__init__.py +0 -0
  146. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/chat_skills.py +0 -0
  147. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/customize_api.py +0 -0
  148. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/files_api.py +0 -0
  149. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/hitl_bridge.py +0 -0
  150. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/project_root.py +0 -0
  151. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/runtime_api.py +0 -0
  152. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/serve_bind.py +0 -0
  153. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/serve_state.py +0 -0
  154. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/sessions_api.py +0 -0
  155. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/terminal_api.py +0 -0
  156. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/terminal_repl.py +0 -0
  157. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/web_config_api.py +0 -0
  158. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/web_sse_compat.py +0 -0
  159. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/web/workspace_panel_api.py +0 -0
  160. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode/workspace_hints.py +0 -0
  161. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode.egg-info/dependency_links.txt +0 -0
  162. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode.egg-info/entry_points.txt +0 -0
  163. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode.egg-info/requires.txt +0 -0
  164. {gemcode-0.4.22 → gemcode-0.4.24}/src/gemcode.egg-info/top_level.txt +0 -0
  165. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_add_dir.py +0 -0
  166. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_agent_habits.py +0 -0
  167. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_agent_instruction.py +0 -0
  168. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_agent_mesh.py +0 -0
  169. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_autocompact.py +0 -0
  170. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_automations.py +0 -0
  171. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_capability_routing.py +0 -0
  172. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_checkpoint_diff_command.py +0 -0
  173. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_cli_init.py +0 -0
  174. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_compress_memory_tool.py +0 -0
  175. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_computer_use_permissions.py +0 -0
  176. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_context_budget.py +0 -0
  177. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_context_warning.py +0 -0
  178. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_credentials.py +0 -0
  179. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_eval_harness_layout.py +0 -0
  180. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_event_bus.py +0 -0
  181. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_files_api.py +0 -0
  182. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_fleet_reports.py +0 -0
  183. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_ide_stdio_attachments.py +0 -0
  184. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_interactive_permission_ask.py +0 -0
  185. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_kaira_ipc_paths.py +0 -0
  186. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_kaira_scheduler.py +0 -0
  187. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_modality_tools.py +0 -0
  188. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_model_error_retry.py +0 -0
  189. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_model_errors.py +0 -0
  190. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_model_routing.py +0 -0
  191. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_multimodal_input.py +0 -0
  192. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_output_styles_and_rules.py +0 -0
  193. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_paths.py +0 -0
  194. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_permissions.py +0 -0
  195. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_prompt_suggestions.py +0 -0
  196. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_repl_commands.py +0 -0
  197. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_repl_slash.py +0 -0
  198. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_session_runtime_cache.py +0 -0
  199. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_skills.py +0 -0
  200. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_slash_commands.py +0 -0
  201. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_slash_completion_registry.py +0 -0
  202. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_thinking_config.py +0 -0
  203. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_token_budget.py +0 -0
  204. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_tool_context_circulation.py +0 -0
  205. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_tools.py +0 -0
  206. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_tools_inspector.py +0 -0
  207. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_web_server.py +0 -0
  208. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_web_sse_adapter.py +0 -0
  209. {gemcode-0.4.22 → gemcode-0.4.24}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.4.22
3
+ Version: 0.4.24
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.4.22"
7
+ version = "0.4.24"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -66,6 +66,10 @@ class Habit:
66
66
  every_seconds: int | None = None # interval in seconds
67
67
  cron: str | None = None # "M H * * *" (minute, hour)
68
68
  daily_at: str | None = None # "HH:MM"
69
+ # Trigger chain (fires when another habit's mesh job completes)
70
+ trigger_after: str | None = None # upstream habit name
71
+ trigger_on: str = "finished" # finished | failed | any
72
+ trigger_cooldown_s: float = 0.0
69
73
  # Metadata
70
74
  priority: int = 0
71
75
  max_runs: int | None = None # None = unlimited
@@ -82,6 +86,9 @@ class Habit:
82
86
  "every_seconds": self.every_seconds,
83
87
  "cron": self.cron,
84
88
  "daily_at": self.daily_at,
89
+ "trigger_after": self.trigger_after,
90
+ "trigger_on": self.trigger_on,
91
+ "trigger_cooldown_s": self.trigger_cooldown_s,
85
92
  "priority": self.priority,
86
93
  "max_runs": self.max_runs,
87
94
  "run_count": self.run_count,
@@ -123,6 +130,9 @@ def load_habits(project_root: Path) -> list[Habit]:
123
130
  every_seconds=item.get("every_seconds"),
124
131
  cron=item.get("cron"),
125
132
  daily_at=item.get("daily_at"),
133
+ trigger_after=(str(item.get("trigger_after") or "").strip().lower() or None),
134
+ trigger_on=str(item.get("trigger_on") or "finished").strip().lower() or "finished",
135
+ trigger_cooldown_s=float(item.get("trigger_cooldown_s") or 0),
126
136
  priority=int(item.get("priority") or 0),
127
137
  max_runs=item.get("max_runs"),
128
138
  run_count=int(item.get("run_count") or 0),
@@ -143,9 +153,11 @@ def save_habits(project_root: Path, habits: list[Habit]) -> None:
143
153
 
144
154
 
145
155
  def _is_due(habit: Habit, now_s: float) -> bool:
146
- """Check if a habit is due to run."""
156
+ """Check if a timer-based habit is due to run (trigger-only habits are never due here)."""
147
157
  if not habit.enabled:
148
158
  return False
159
+ if (habit.trigger_after or "").strip():
160
+ return False
149
161
  if habit.max_runs is not None and habit.run_count >= habit.max_runs:
150
162
  return False
151
163
 
@@ -124,6 +124,13 @@ class AgentMesh:
124
124
  except Exception:
125
125
  pass
126
126
 
127
+ self._habit_chain_engine = None
128
+ try:
129
+ from gemcode.habit_chains import HabitChainEngine
130
+ self._habit_chain_engine = HabitChainEngine(cfg)
131
+ except Exception:
132
+ pass
133
+
127
134
  # Initialize delegation learning
128
135
  self._learner = None
129
136
  try:
@@ -213,6 +220,8 @@ class AgentMesh:
213
220
 
214
221
  if self._trigger_engine is not None:
215
222
  self._trigger_engine.start()
223
+ if self._habit_chain_engine is not None:
224
+ self._habit_chain_engine.start()
216
225
  if self._habit_scheduler is not None:
217
226
  self._habit_scheduler.start()
218
227
 
@@ -227,6 +236,8 @@ class AgentMesh:
227
236
  self._scheduler_task.cancel()
228
237
  if self._trigger_engine is not None:
229
238
  self._trigger_engine.stop()
239
+ if self._habit_chain_engine is not None:
240
+ self._habit_chain_engine.stop()
230
241
  if self._habit_scheduler is not None:
231
242
  self._habit_scheduler.stop()
232
243
  # Signal the background loop to exit
@@ -628,6 +639,23 @@ class AgentMesh:
628
639
  if isinstance(hm, dict):
629
640
  pl["habit"] = hm
630
641
  maybe_append_job_report(fleet_root, pl)
642
+ try:
643
+ from gemcode.habit_runs import append_habit_run
644
+
645
+ hm = job.meta.get("habit") if isinstance(job.meta, dict) else None
646
+ if isinstance(hm, dict) and hm.get("name"):
647
+ append_habit_run(
648
+ fleet_root,
649
+ habit_name=str(hm.get("name") or ""),
650
+ agent=str(hm.get("agent") or job.member_name or ""),
651
+ job_id=job.job_id,
652
+ status="finished",
653
+ report=result_text,
654
+ duration_ms=duration_ms,
655
+ session_id=job.session_id,
656
+ )
657
+ except Exception:
658
+ pass
631
659
  except Exception:
632
660
  pass
633
661
 
@@ -667,6 +695,23 @@ class AgentMesh:
667
695
  if isinstance(hm, dict):
668
696
  plf["habit"] = hm
669
697
  maybe_append_job_report(fleet_root, plf)
698
+ try:
699
+ from gemcode.habit_runs import append_habit_run
700
+
701
+ hm = job.meta.get("habit") if isinstance(job.meta, dict) else None
702
+ if isinstance(hm, dict) and hm.get("name"):
703
+ append_habit_run(
704
+ fleet_root,
705
+ habit_name=str(hm.get("name") or ""),
706
+ agent=str(hm.get("agent") or job.member_name or ""),
707
+ job_id=job.job_id,
708
+ status="failed",
709
+ report="",
710
+ error=job.error,
711
+ session_id=job.session_id,
712
+ )
713
+ except Exception:
714
+ pass
670
715
  except Exception:
671
716
  pass
672
717
 
@@ -959,6 +1004,9 @@ def _sync_mesh_cfg_project_root(mesh: AgentMesh, cfg: GemCodeConfig) -> None:
959
1004
  if mesh._trigger_engine is not None:
960
1005
  mesh._trigger_engine.cfg = mesh.cfg
961
1006
  mesh._trigger_engine.reload()
1007
+ if mesh._habit_chain_engine is not None:
1008
+ mesh._habit_chain_engine.cfg = mesh.cfg
1009
+ mesh._habit_chain_engine.reload()
962
1010
  if mesh._habit_scheduler is not None:
963
1011
  mesh._habit_scheduler.cfg = mesh.cfg
964
1012
  if mesh._learner is not None:
@@ -0,0 +1,219 @@
1
+ """Habit chains — scheduled tasks that fire when another habit completes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from typing import Any
9
+
10
+ from gemcode.agent_habits import Habit, load_habits, save_habits
11
+ from gemcode.config import GemCodeConfig
12
+ from gemcode.event_bus import BusMessage, get_bus
13
+
14
+
15
+ def _enabled() -> bool:
16
+ return os.environ.get("GEMCODE_HABIT_CHAINS", "1").strip().lower() in (
17
+ "1",
18
+ "true",
19
+ "yes",
20
+ "on",
21
+ )
22
+
23
+
24
+ def _status_matches(trigger_on: str, actual: str) -> bool:
25
+ want = (trigger_on or "finished").strip().lower()
26
+ got = (actual or "").strip().lower()
27
+ if want in ("", "any", "*"):
28
+ return True
29
+ return got == want
30
+
31
+
32
+ def render_chain_prompt(
33
+ prompt: str,
34
+ *,
35
+ source_habit: str,
36
+ source_status: str,
37
+ source_report: str = "",
38
+ source_error: str = "",
39
+ ) -> str:
40
+ """Substitute template vars from the upstream habit run."""
41
+ body = (source_report or source_error or "").strip()
42
+ out = (prompt or "").replace("{{source_habit}}", source_habit)
43
+ out = out.replace("{{source_status}}", source_status)
44
+ out = out.replace("{{source_report}}", body)
45
+ out = out.replace("{{report}}", body)
46
+ if body and "{{" not in prompt and body not in prompt:
47
+ out = (
48
+ f"{out.rstrip()}\n\n"
49
+ f"---\n"
50
+ f"Output from upstream task `{source_habit}` ({source_status}):\n"
51
+ f"{body}"
52
+ )
53
+ return out
54
+
55
+
56
+ def habits_watching(source_name: str, habits: list[Habit] | None = None) -> list[Habit]:
57
+ """Return enabled trigger-habits that listen for ``source_name`` completions."""
58
+ src = (source_name or "").strip().lower()
59
+ if not src:
60
+ return []
61
+ if habits is None:
62
+ return []
63
+ out: list[Habit] = []
64
+ for h in habits:
65
+ if not h.enabled:
66
+ continue
67
+ after = (h.trigger_after or "").strip().lower()
68
+ if after and after == src:
69
+ out.append(h)
70
+ return out
71
+
72
+
73
+ def would_create_habit_cycle(
74
+ habits: list[Habit],
75
+ *,
76
+ name: str,
77
+ trigger_after: str,
78
+ ) -> bool:
79
+ """True if adding ``name`` → ``trigger_after`` closes a directed cycle."""
80
+ nm = (name or "").strip().lower()
81
+ after = (trigger_after or "").strip().lower()
82
+ if not nm or not after:
83
+ return False
84
+
85
+ edges: dict[str, str] = {}
86
+ for h in habits:
87
+ if h.name == nm:
88
+ continue
89
+ ta = (h.trigger_after or "").strip().lower()
90
+ if ta:
91
+ edges[h.name] = ta
92
+ edges[nm] = after
93
+
94
+ visited: set[str] = set()
95
+ stack: set[str] = set()
96
+
97
+ def dfs(node: str) -> bool:
98
+ if node in stack:
99
+ return True
100
+ if node in visited:
101
+ return False
102
+ visited.add(node)
103
+ stack.add(node)
104
+ nxt = edges.get(node)
105
+ if nxt and dfs(nxt):
106
+ return True
107
+ stack.remove(node)
108
+ return False
109
+
110
+ return dfs(nm)
111
+
112
+
113
+ class HabitChainEngine:
114
+ """
115
+ When a habit-backed mesh job finishes, enqueue downstream trigger-habits.
116
+
117
+ Watches ``job.report`` on the in-process bus (same as agent triggers).
118
+ """
119
+
120
+ def __init__(self, cfg: GemCodeConfig) -> None:
121
+ self.cfg = cfg
122
+ self._bus = get_bus()
123
+ self._active = False
124
+ self._last_fired: dict[str, float] = {}
125
+ self._sub = self._bus.subscribe(topic="job.report", callback=self._on_job_report)
126
+
127
+ def start(self) -> None:
128
+ self._active = _enabled()
129
+
130
+ def stop(self) -> None:
131
+ self._active = False
132
+ self._sub.unsubscribe()
133
+
134
+ def reload(self) -> None:
135
+ self._last_fired.clear()
136
+
137
+ async def _on_job_report(self, msg: BusMessage) -> None:
138
+ if not self._active:
139
+ return
140
+ payload = msg.payload if isinstance(msg.payload, dict) else {}
141
+ hab = payload.get("habit")
142
+ if not isinstance(hab, dict):
143
+ return
144
+ source_name = str(hab.get("name") or "").strip().lower()
145
+ if not source_name:
146
+ return
147
+
148
+ status = str(payload.get("status") or "").strip().lower()
149
+ habits = load_habits(self.cfg.project_root)
150
+ watchers = habits_watching(source_name, habits)
151
+ if not watchers:
152
+ return
153
+
154
+ now = time.time()
155
+ changed = False
156
+ fired: list[str] = []
157
+
158
+ for target in watchers:
159
+ if not _status_matches(target.trigger_on, status):
160
+ continue
161
+ cd = max(0.0, float(target.trigger_cooldown_s or 0))
162
+ last = self._last_fired.get(target.name, 0.0)
163
+ if cd and (now - last) < cd:
164
+ continue
165
+ if target.max_runs is not None and target.run_count >= target.max_runs:
166
+ continue
167
+
168
+ prompt = render_chain_prompt(
169
+ target.prompt,
170
+ source_habit=source_name,
171
+ source_status=status,
172
+ source_report=str(payload.get("report") or ""),
173
+ source_error=str(payload.get("error") or ""),
174
+ )
175
+
176
+ try:
177
+ from gemcode.agent_mesh import get_mesh
178
+
179
+ mesh = get_mesh(self.cfg)
180
+ if mesh is None:
181
+ continue
182
+
183
+ mesh.enqueue(
184
+ prompt=prompt,
185
+ priority=max(target.priority, 1),
186
+ member_name=target.agent,
187
+ meta={
188
+ "habit": {"name": target.name, "agent": target.agent},
189
+ "chain": {
190
+ "after": source_name,
191
+ "on_status": status,
192
+ "source_job_id": str(payload.get("job_id") or ""),
193
+ },
194
+ },
195
+ )
196
+ target.last_run_ms = int(now * 1000)
197
+ target.run_count += 1
198
+ self._last_fired[target.name] = now
199
+ changed = True
200
+ fired.append(target.name)
201
+ except Exception:
202
+ pass
203
+
204
+ if changed:
205
+ save_habits(self.cfg.project_root, habits)
206
+ try:
207
+ await self._bus.publish(
208
+ BusMessage(
209
+ topic="habit.chain",
210
+ from_addr="habit-chain",
211
+ payload={
212
+ "source": source_name,
213
+ "status": status,
214
+ "triggered": fired,
215
+ },
216
+ )
217
+ )
218
+ except Exception:
219
+ pass
@@ -0,0 +1,137 @@
1
+ """Persistent audit log for scheduled habit (mesh job) completions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def _habit_runs_path(fleet_root: Path) -> Path:
12
+ return fleet_root / ".gemcode" / "habit_runs.jsonl"
13
+
14
+
15
+ def append_habit_run(
16
+ fleet_root: Path,
17
+ *,
18
+ habit_name: str,
19
+ agent: str = "",
20
+ job_id: str = "",
21
+ status: str = "",
22
+ report: str = "",
23
+ error: str = "",
24
+ duration_ms: int | None = None,
25
+ session_id: str = "",
26
+ ts_ms: int | None = None,
27
+ ) -> None:
28
+ """Append one habit run record (never drained — unlike fleet_reports.jsonl)."""
29
+ nm = (habit_name or "").strip().lower()
30
+ if not nm:
31
+ return
32
+ try:
33
+ p = _habit_runs_path(fleet_root)
34
+ p.parent.mkdir(parents=True, exist_ok=True)
35
+ rec: dict[str, Any] = {
36
+ "ts_ms": int(ts_ms or int(time.time() * 1000)),
37
+ "habit_name": nm,
38
+ "agent": (agent or "").strip(),
39
+ "job_id": (job_id or "").strip(),
40
+ "status": (status or "").strip().lower() or "unknown",
41
+ "report": (report or "").strip()[:12_000],
42
+ "error": (error or "").strip()[:4000],
43
+ "session_id": (session_id or "").strip(),
44
+ }
45
+ if duration_ms is not None:
46
+ rec["duration_ms"] = int(duration_ms)
47
+ line = json.dumps(rec, ensure_ascii=False, default=str) + "\n"
48
+ with open(p, "a", encoding="utf-8") as f:
49
+ f.write(line)
50
+ f.flush()
51
+ except OSError:
52
+ pass
53
+
54
+
55
+ def _habit_name_from_payload(payload: dict[str, Any]) -> str:
56
+ hab = payload.get("habit")
57
+ if isinstance(hab, dict):
58
+ return str(hab.get("name") or "").strip().lower()
59
+ return ""
60
+
61
+
62
+ def _record_from_job_report(rec: dict[str, Any]) -> dict[str, Any] | None:
63
+ if str(rec.get("topic") or "") != "job.report":
64
+ return None
65
+ payload = rec.get("payload")
66
+ if not isinstance(payload, dict):
67
+ return None
68
+ habit_name = _habit_name_from_payload(payload)
69
+ if not habit_name:
70
+ return None
71
+ hab = payload.get("habit") if isinstance(payload.get("habit"), dict) else {}
72
+ return {
73
+ "ts_ms": int(rec.get("ts_ms") or int(time.time() * 1000)),
74
+ "habit_name": habit_name,
75
+ "agent": str(hab.get("agent") or payload.get("member") or "").strip(),
76
+ "job_id": str(payload.get("job_id") or "").strip(),
77
+ "status": str(payload.get("status") or "").strip().lower() or "unknown",
78
+ "report": str(payload.get("report") or "").strip()[:12_000],
79
+ "error": str(payload.get("error") or "").strip()[:4000],
80
+ "session_id": str(payload.get("session_id") or "").strip(),
81
+ "duration_ms": payload.get("duration_ms"),
82
+ "pending_inbox": True,
83
+ }
84
+
85
+
86
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
87
+ if not path.is_file():
88
+ return []
89
+ try:
90
+ raw = path.read_text(encoding="utf-8", errors="replace")
91
+ except OSError:
92
+ return []
93
+ out: list[dict[str, Any]] = []
94
+ for line in raw.splitlines():
95
+ line = line.strip()
96
+ if not line:
97
+ continue
98
+ try:
99
+ rec = json.loads(line)
100
+ except json.JSONDecodeError:
101
+ continue
102
+ if isinstance(rec, dict):
103
+ out.append(rec)
104
+ return out
105
+
106
+
107
+ def list_habit_runs(
108
+ fleet_root: Path,
109
+ *,
110
+ habit_name: str,
111
+ limit: int = 50,
112
+ ) -> list[dict[str, Any]]:
113
+ """Return recent runs for one habit, newest first. Merges audit log + pending inbox."""
114
+ nm = (habit_name or "").strip().lower()
115
+ if not nm:
116
+ return []
117
+
118
+ limit = max(1, min(200, int(limit)))
119
+ by_job: dict[str, dict[str, Any]] = {}
120
+
121
+ for rec in _read_jsonl(_habit_runs_path(fleet_root)):
122
+ if str(rec.get("habit_name") or "").strip().lower() != nm:
123
+ continue
124
+ jid = str(rec.get("job_id") or "").strip() or f"run-{rec.get('ts_ms')}"
125
+ by_job[jid] = {k: v for k, v in rec.items() if k != "pending_inbox"}
126
+
127
+ inbox_path = fleet_root / ".gemcode" / "fleet_reports.jsonl"
128
+ for rec in _read_jsonl(inbox_path):
129
+ row = _record_from_job_report(rec)
130
+ if row is None or row["habit_name"] != nm:
131
+ continue
132
+ jid = row["job_id"] or f"pending-{row['ts_ms']}"
133
+ if jid not in by_job:
134
+ by_job[jid] = row
135
+
136
+ runs = sorted(by_job.values(), key=lambda r: int(r.get("ts_ms") or 0), reverse=True)
137
+ return runs[:limit]
@@ -247,15 +247,18 @@ async def run_turn(
247
247
  REQUEST_CONFIRMATION_FC = "adk_request_confirmation"
248
248
 
249
249
  def _get_confirmation_requests(events: list) -> list[types.FunctionCall]:
250
- out: list[types.FunctionCall] = []
251
- for ev in events:
250
+ """Return confirmation FCs from the last event in the batch that has any."""
251
+ for ev in reversed(events):
252
252
  try:
253
- for fc in ev.get_function_calls() or []:
254
- if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC:
255
- out.append(fc)
253
+ fcs = [
254
+ fc for fc in (ev.get_function_calls() or [])
255
+ if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC
256
+ ]
257
+ if fcs:
258
+ return fcs
256
259
  except Exception:
257
260
  continue
258
- return out
261
+ return []
259
262
 
260
263
  def _extract_hint_and_tool(fc: types.FunctionCall) -> tuple[str, str]:
261
264
  tool_name = "unknown_tool"
@@ -784,15 +784,17 @@ class KairaDaemon:
784
784
  )
785
785
 
786
786
  def _get_confirmation_requests(events: list) -> list:
787
- out: list = []
788
- for ev in events:
787
+ for ev in reversed(events):
789
788
  try:
790
- for fc in ev.get_function_calls() or []:
791
- if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC:
792
- out.append(fc)
789
+ fcs = [
790
+ fc for fc in (ev.get_function_calls() or [])
791
+ if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC
792
+ ]
793
+ if fcs:
794
+ return fcs
793
795
  except Exception:
794
796
  continue
795
- return out
797
+ return []
796
798
 
797
799
  def _extract_hint_and_tool(fc) -> tuple[str, str]:
798
800
  tool_name = "unknown_tool"
@@ -15,6 +15,26 @@ def trust_json_path() -> Path:
15
15
  return _trust_file_path()
16
16
 
17
17
 
18
+ def _hosted_tenant_root() -> Path | None:
19
+ """Locked workspace root when ``GEMCODE_HOSTED_TENANT_ROOT`` is set."""
20
+ raw = os.environ.get("GEMCODE_HOSTED_TENANT_ROOT", "").strip()
21
+ if not raw:
22
+ return None
23
+ return Path(raw).expanduser().resolve()
24
+
25
+
26
+ def _is_within_root(root: Path, candidate: Path) -> bool:
27
+ try:
28
+ candidate.resolve().relative_to(root.resolve())
29
+ return True
30
+ except ValueError:
31
+ return candidate.resolve() == root.resolve()
32
+
33
+
34
+ def is_hosted_mode() -> bool:
35
+ return _hosted_tenant_root() is not None
36
+
37
+
18
38
  def load_trusted_roots() -> set[str]:
19
39
  """
20
40
  Returns a set of resolved absolute paths (as strings) that are trusted.
@@ -44,8 +64,31 @@ def save_trusted_roots(roots: set[str]) -> None:
44
64
 
45
65
 
46
66
  def is_trusted_root(root: Path) -> bool:
47
- r = str(root.resolve())
48
- return r in load_trusted_roots()
67
+ r = root.resolve()
68
+ locked = _hosted_tenant_root()
69
+ if locked is not None and _is_within_root(locked, r):
70
+ return True
71
+ return str(r) in load_trusted_roots()
72
+
73
+
74
+ def ensure_hosted_workspace_trust(root: Path | None = None) -> bool:
75
+ """
76
+ In hosted multi-tenant mode, trust the tenant workspace automatically.
77
+
78
+ Interactive folder-trust prompts are not used on shared infrastructure — the
79
+ platform already isolates each user to their PVC. Persists trust for tooling
80
+ that reads ``trust.json`` directly.
81
+ """
82
+ locked = _hosted_tenant_root()
83
+ if locked is None:
84
+ return False
85
+ target = (root or locked).resolve()
86
+ if not _is_within_root(locked, target):
87
+ target = locked
88
+ trust_root(locked, trusted=True)
89
+ if target != locked:
90
+ trust_root(target, trusted=True)
91
+ return True
49
92
 
50
93
 
51
94
  def trust_root(root: Path, *, trusted: bool) -> None: