gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -439,6 +439,7 @@ async def evaluate_all_lifecycle_workflows(
439
439
  detect_plan_mode_fn: Any,
440
440
  check_premature_stop_fn: Any,
441
441
  context_data: dict[str, Any] | None = None,
442
+ detect_plan_mode_from_context_fn: Any | None = None,
442
443
  ) -> HookResponse:
443
444
  """
444
445
  Discover and evaluate all lifecycle workflows for the given event.
@@ -453,9 +454,10 @@ async def evaluate_all_lifecycle_workflows(
453
454
  action_executor: Action executor for running actions
454
455
  evaluator: Condition evaluator
455
456
  detect_task_claim_fn: Function to detect task claims
456
- detect_plan_mode_fn: Function to detect plan mode
457
+ detect_plan_mode_fn: Function to detect plan mode (from tool calls)
457
458
  check_premature_stop_fn: Async function to check premature stop
458
459
  context_data: Optional context data passed between actions
460
+ detect_plan_mode_from_context_fn: Function to detect plan mode from system reminders
459
461
 
460
462
  Returns:
461
463
  Merged HookResponse with combined context and first non-allow decision.
@@ -594,6 +596,21 @@ async def evaluate_all_lifecycle_workflows(
594
596
  detect_plan_mode_fn(event, state)
595
597
  state_manager.save_state(state)
596
598
 
599
+ # Detect plan mode from system reminders for BEFORE_AGENT events
600
+ # This catches plan mode when user enters via UI (not via EnterPlanMode tool)
601
+ if event.event_type == HookEventType.BEFORE_AGENT and detect_plan_mode_from_context_fn:
602
+ session_id = event.metadata.get("_platform_session_id")
603
+ if session_id:
604
+ state = state_manager.get_state(session_id)
605
+ if state is None:
606
+ state = WorkflowState(
607
+ session_id=session_id,
608
+ workflow_name="__lifecycle__",
609
+ step="",
610
+ )
611
+ detect_plan_mode_from_context_fn(event, state)
612
+ state_manager.save_state(state)
613
+
597
614
  # Check for premature stop in active step workflows on STOP events
598
615
  if event.event_type == HookEventType.STOP:
599
616
  premature_response = await check_premature_stop_fn(event, context_data)
@@ -610,4 +627,15 @@ async def evaluate_all_lifecycle_workflows(
610
627
  reason=final_reason,
611
628
  context="\n\n".join(all_context) if all_context else None,
612
629
  system_message=final_system_message,
630
+ metadata={
631
+ "discovered_workflows": [
632
+ {
633
+ "name": w.name,
634
+ "priority": w.priority,
635
+ "is_project": w.is_project,
636
+ "path": str(w.path),
637
+ }
638
+ for w in workflows
639
+ ]
640
+ },
613
641
  )
@@ -68,3 +68,33 @@ async def call_llm(
68
68
  except Exception as e:
69
69
  logger.error(f"call_llm: Failed: {e}")
70
70
  return {"error": str(e)}
71
+
72
+
73
+ # --- ActionHandler-compatible wrappers ---
74
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
75
+
76
+ if __name__ != "__main__":
77
+ from typing import TYPE_CHECKING
78
+
79
+ if TYPE_CHECKING:
80
+ from gobby.workflows.actions import ActionContext
81
+
82
+
83
+ async def handle_call_llm(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
84
+ """ActionHandler wrapper for call_llm."""
85
+ if context.session_manager is None:
86
+ return {"error": "Session manager not available"}
87
+
88
+ session = context.session_manager.get(context.session_id)
89
+ if session is None:
90
+ return {"error": f"Session not found: {context.session_id}"}
91
+
92
+ return await call_llm(
93
+ llm_service=context.llm_service,
94
+ template_engine=context.template_engine,
95
+ state=context.state,
96
+ session=session,
97
+ prompt=kwargs.get("prompt"),
98
+ output_as=kwargs.get("output_as"),
99
+ **{k: v for k, v in kwargs.items() if k not in ("prompt", "output_as")},
100
+ )
gobby/workflows/loader.py CHANGED
@@ -193,6 +193,7 @@ class WorkflowLoader:
193
193
  return self._discovery_cache[cache_key]
194
194
 
195
195
  discovered: dict[str, DiscoveredWorkflow] = {} # name -> workflow (for shadowing)
196
+ failed: dict[str, str] = {} # name -> error message for failed workflows
196
197
 
197
198
  # 1. Scan global lifecycle directory first (will be shadowed by project)
198
199
  for global_dir in self.global_dirs:
@@ -201,7 +202,14 @@ class WorkflowLoader:
201
202
  # 2. Scan project lifecycle directory (shadows global)
202
203
  if project_path:
203
204
  project_dir = Path(project_path) / ".gobby" / "workflows" / "lifecycle"
204
- self._scan_directory(project_dir, is_project=True, discovered=discovered)
205
+ self._scan_directory(project_dir, is_project=True, discovered=discovered, failed=failed)
206
+
207
+ # Log errors when project workflow fails but global exists (failed shadowing)
208
+ for name, error in failed.items():
209
+ if name in discovered and not discovered[name].is_project:
210
+ logger.error(
211
+ f"Project workflow '{name}' failed to load, using global instead: {error}"
212
+ )
205
213
 
206
214
  # 3. Filter to lifecycle workflows only
207
215
  lifecycle_workflows = [w for w in discovered.values() if w.definition.type == "lifecycle"]
@@ -225,6 +233,7 @@ class WorkflowLoader:
225
233
  directory: Path,
226
234
  is_project: bool,
227
235
  discovered: dict[str, DiscoveredWorkflow],
236
+ failed: dict[str, str] | None = None,
228
237
  ) -> None:
229
238
  """
230
239
  Scan a directory for workflow YAML files and add to discovered dict.
@@ -233,6 +242,7 @@ class WorkflowLoader:
233
242
  directory: Directory to scan
234
243
  is_project: Whether this is a project directory (for shadowing)
235
244
  discovered: Dict to update (name -> DiscoveredWorkflow)
245
+ failed: Optional dict to track failed workflows (name -> error message)
236
246
  """
237
247
  if not directory.exists():
238
248
  return
@@ -258,6 +268,8 @@ class WorkflowLoader:
258
268
  data = self._merge_workflows(parent.model_dump(), data)
259
269
  except ValueError as e:
260
270
  logger.warning(f"Skipping workflow {name}: {e}")
271
+ if failed is not None:
272
+ failed[name] = str(e)
261
273
  continue
262
274
 
263
275
  definition = WorkflowDefinition(**data)
@@ -267,6 +279,10 @@ class WorkflowLoader:
267
279
  if definition.settings and "priority" in definition.settings:
268
280
  priority = definition.settings["priority"]
269
281
 
282
+ # Log successful shadowing when project workflow overrides global
283
+ if name in discovered and is_project and not discovered[name].is_project:
284
+ logger.info(f"Project workflow '{name}' shadows global workflow")
285
+
270
286
  # Project workflows shadow global (overwrite in dict)
271
287
  # Global is scanned first, so project overwrites
272
288
  discovered[name] = DiscoveredWorkflow(
@@ -279,6 +295,8 @@ class WorkflowLoader:
279
295
 
280
296
  except Exception as e:
281
297
  logger.warning(f"Failed to load workflow from {yaml_path}: {e}")
298
+ if failed is not None:
299
+ failed[name] = str(e)
282
300
 
283
301
  def clear_cache(self) -> None:
284
302
  """
@@ -288,11 +306,6 @@ class WorkflowLoader:
288
306
  self._cache.clear()
289
307
  self._discovery_cache.clear()
290
308
 
291
- def clear_discovery_cache(self) -> None:
292
- """Clear the discovery cache. Call when workflows may have changed."""
293
- # Deprecated: use clear_cache instead to clear everything
294
- self.clear_cache()
295
-
296
309
  def validate_workflow_for_agent(
297
310
  self,
298
311
  workflow_name: str,
@@ -5,7 +5,10 @@ These functions handle MCP tool calls from workflows.
5
5
  """
6
6
 
7
7
  import logging
8
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from gobby.workflows.actions import ActionContext
9
12
 
10
13
  logger = logging.getLogger(__name__)
11
14
 
@@ -58,3 +61,19 @@ async def call_mcp_tool(
58
61
  except Exception as e:
59
62
  logger.error(f"call_mcp_tool: Failed: {e}")
60
63
  return {"error": str(e)}
64
+
65
+
66
+ # --- ActionHandler-compatible wrappers ---
67
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
68
+
69
+
70
+ async def handle_call_mcp_tool(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
71
+ """ActionHandler wrapper for call_mcp_tool."""
72
+ return await call_mcp_tool(
73
+ mcp_manager=context.mcp_manager,
74
+ state=context.state,
75
+ server_name=kwargs.get("server_name"),
76
+ tool_name=kwargs.get("tool_name"),
77
+ arguments=kwargs.get("arguments"),
78
+ output_as=kwargs.get("as"),
79
+ )
@@ -270,3 +270,157 @@ def reset_memory_injection_tracking(state: Any | None = None) -> dict[str, Any]:
270
270
  logger.info(f"reset_memory_injection_tracking: Cleared {cleared_count} injected memory IDs")
271
271
 
272
272
  return {"success": True, "cleared": cleared_count}
273
+
274
+
275
+ async def memory_extract(
276
+ session_manager: Any,
277
+ session_id: str,
278
+ llm_service: Any,
279
+ memory_manager: Any,
280
+ transcript_processor: Any | None = None,
281
+ min_importance: float = 0.7,
282
+ max_memories: int = 5,
283
+ dry_run: bool = False,
284
+ ) -> dict[str, Any] | None:
285
+ """Extract memories from a session transcript.
286
+
287
+ Uses LLM analysis to identify high-value, reusable knowledge from
288
+ session transcripts and stores them as memories.
289
+
290
+ Args:
291
+ session_manager: The session manager instance
292
+ session_id: Current session ID
293
+ llm_service: LLM service for analysis
294
+ memory_manager: Memory manager for storage
295
+ transcript_processor: Optional transcript processor
296
+ min_importance: Minimum importance threshold (0.0-1.0)
297
+ max_memories: Maximum memories to extract
298
+ dry_run: If True, don't store memories
299
+
300
+ Returns:
301
+ Dict with extracted_count and memory details, or error
302
+ """
303
+ if not memory_manager:
304
+ return {"error": "Memory Manager not available"}
305
+
306
+ if not memory_manager.config.enabled:
307
+ logger.debug("memory_extract: Memory system disabled")
308
+ return None
309
+
310
+ if not llm_service:
311
+ return {"error": "LLM service not available"}
312
+
313
+ try:
314
+ from gobby.memory.extractor import SessionMemoryExtractor
315
+
316
+ extractor = SessionMemoryExtractor(
317
+ memory_manager=memory_manager,
318
+ session_manager=session_manager,
319
+ llm_service=llm_service,
320
+ transcript_processor=transcript_processor,
321
+ )
322
+
323
+ candidates = await extractor.extract(
324
+ session_id=session_id,
325
+ min_importance=min_importance,
326
+ max_memories=max_memories,
327
+ dry_run=dry_run,
328
+ )
329
+
330
+ if not candidates:
331
+ logger.debug(f"memory_extract: No memories extracted from session {session_id}")
332
+ return {"extracted_count": 0, "memories": []}
333
+
334
+ logger.info(
335
+ f"memory_extract: Extracted {len(candidates)} memories from session {session_id}"
336
+ )
337
+
338
+ return {
339
+ "extracted_count": len(candidates),
340
+ "memories": [c.to_dict() for c in candidates],
341
+ "dry_run": dry_run,
342
+ }
343
+
344
+ except Exception as e:
345
+ logger.error(f"memory_extract: Failed: {e}", exc_info=True)
346
+ return {"error": str(e)}
347
+
348
+
349
+ # --- ActionHandler-compatible wrappers ---
350
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
351
+
352
+ if __name__ != "__main__":
353
+ from typing import TYPE_CHECKING
354
+
355
+ if TYPE_CHECKING:
356
+ from gobby.workflows.actions import ActionContext
357
+
358
+
359
+ async def handle_memory_sync_import(
360
+ context: "ActionContext", **kwargs: Any
361
+ ) -> dict[str, Any] | None:
362
+ """ActionHandler wrapper for memory_sync_import."""
363
+ return await memory_sync_import(context.memory_sync_manager)
364
+
365
+
366
+ async def handle_memory_sync_export(
367
+ context: "ActionContext", **kwargs: Any
368
+ ) -> dict[str, Any] | None:
369
+ """ActionHandler wrapper for memory_sync_export."""
370
+ return await memory_sync_export(context.memory_sync_manager)
371
+
372
+
373
+ async def handle_memory_save(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
374
+ """ActionHandler wrapper for memory_save."""
375
+ return await memory_save(
376
+ memory_manager=context.memory_manager,
377
+ session_manager=context.session_manager,
378
+ session_id=context.session_id,
379
+ content=kwargs.get("content"),
380
+ memory_type=kwargs.get("memory_type", "fact"),
381
+ importance=kwargs.get("importance", 0.5),
382
+ tags=kwargs.get("tags"),
383
+ project_id=kwargs.get("project_id"),
384
+ )
385
+
386
+
387
+ async def handle_memory_recall_relevant(
388
+ context: "ActionContext", **kwargs: Any
389
+ ) -> dict[str, Any] | None:
390
+ """ActionHandler wrapper for memory_recall_relevant."""
391
+ prompt_text = None
392
+ if context.event_data:
393
+ # Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
394
+ prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
395
+
396
+ return await memory_recall_relevant(
397
+ memory_manager=context.memory_manager,
398
+ session_manager=context.session_manager,
399
+ session_id=context.session_id,
400
+ prompt_text=prompt_text,
401
+ project_id=kwargs.get("project_id"),
402
+ limit=kwargs.get("limit", 5),
403
+ min_importance=kwargs.get("min_importance", 0.3),
404
+ state=context.state,
405
+ )
406
+
407
+
408
+ async def handle_reset_memory_injection_tracking(
409
+ context: "ActionContext", **kwargs: Any
410
+ ) -> dict[str, Any] | None:
411
+ """ActionHandler wrapper for reset_memory_injection_tracking."""
412
+ return reset_memory_injection_tracking(state=context.state)
413
+
414
+
415
+ async def handle_memory_extract(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
416
+ """ActionHandler wrapper for memory_extract."""
417
+ return await memory_extract(
418
+ session_manager=context.session_manager,
419
+ session_id=context.session_id,
420
+ llm_service=context.llm_service,
421
+ memory_manager=context.memory_manager,
422
+ transcript_processor=context.transcript_processor,
423
+ min_importance=kwargs.get("min_importance", 0.7),
424
+ max_memories=kwargs.get("max_memories", 5),
425
+ dry_run=kwargs.get("dry_run", False),
426
+ )
@@ -0,0 +1,183 @@
1
+ """Safe expression evaluation utilities.
2
+
3
+ Provides AST-based expression evaluation without using eval(),
4
+ and lazy boolean evaluation for deferred computation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import operator
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ __all__ = ["LazyBool", "SafeExpressionEvaluator"]
15
+
16
+
17
+ class LazyBool:
18
+ """Lazy boolean that defers computation until first access.
19
+
20
+ Used to avoid expensive operations (git status, DB queries) when
21
+ evaluating block_tools conditions that don't reference certain values.
22
+
23
+ The computation is triggered when the value is used in a boolean context
24
+ (e.g., `if lazy_val:` or `not lazy_val`), which happens during eval().
25
+ """
26
+
27
+ __slots__ = ("_thunk", "_computed", "_value")
28
+
29
+ def __init__(self, thunk: Callable[[], bool]) -> None:
30
+ self._thunk = thunk
31
+ self._computed = False
32
+ self._value = False
33
+
34
+ def __bool__(self) -> bool:
35
+ if not self._computed:
36
+ self._value = self._thunk()
37
+ self._computed = True
38
+ return self._value
39
+
40
+ def __repr__(self) -> str:
41
+ if self._computed:
42
+ return f"LazyBool({self._value})"
43
+ return "LazyBool(<not computed>)"
44
+
45
+
46
+ class SafeExpressionEvaluator(ast.NodeVisitor):
47
+ """Safe expression evaluator using AST.
48
+
49
+ Evaluates simple Python expressions without using eval().
50
+ Supports boolean operations, comparisons, attribute access, subscripts,
51
+ and a limited set of allowed function calls.
52
+ """
53
+
54
+ # Comparison operators mapping
55
+ CMP_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
56
+ ast.Eq: operator.eq,
57
+ ast.NotEq: operator.ne,
58
+ ast.Lt: operator.lt,
59
+ ast.LtE: operator.le,
60
+ ast.Gt: operator.gt,
61
+ ast.GtE: operator.ge,
62
+ ast.Is: operator.is_,
63
+ ast.IsNot: operator.is_not,
64
+ ast.In: lambda a, b: a in b,
65
+ ast.NotIn: lambda a, b: a not in b,
66
+ }
67
+
68
+ def __init__(
69
+ self, context: dict[str, Any], allowed_funcs: dict[str, Callable[..., Any]]
70
+ ) -> None:
71
+ self.context = context
72
+ self.allowed_funcs = allowed_funcs
73
+
74
+ def evaluate(self, expr: str) -> bool:
75
+ """Evaluate expression and return boolean result."""
76
+ try:
77
+ tree = ast.parse(expr, mode="eval")
78
+ return bool(self.visit(tree.body))
79
+ except Exception as e:
80
+ raise ValueError(f"Invalid expression: {e}") from e
81
+
82
+ def visit_BoolOp(self, node: ast.BoolOp) -> bool:
83
+ """Handle 'and' / 'or' operations."""
84
+ if isinstance(node.op, ast.And):
85
+ return all(self.visit(v) for v in node.values)
86
+ elif isinstance(node.op, ast.Or):
87
+ return any(self.visit(v) for v in node.values)
88
+ raise ValueError(f"Unsupported boolean operator: {type(node.op).__name__}")
89
+
90
+ def visit_Compare(self, node: ast.Compare) -> bool:
91
+ """Handle comparison operations (==, !=, <, >, in, not in, etc.)."""
92
+ left = self.visit(node.left)
93
+ for op, comparator in zip(node.ops, node.comparators, strict=False):
94
+ right = self.visit(comparator)
95
+ op_func = self.CMP_OPS.get(type(op))
96
+ if op_func is None:
97
+ raise ValueError(f"Unsupported comparison: {type(op).__name__}")
98
+ if not op_func(left, right):
99
+ return False
100
+ left = right
101
+ return True
102
+
103
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
104
+ """Handle unary operations (not, -, +)."""
105
+ operand = self.visit(node.operand)
106
+ if isinstance(node.op, ast.Not):
107
+ return not operand
108
+ elif isinstance(node.op, ast.USub):
109
+ return -operand
110
+ elif isinstance(node.op, ast.UAdd):
111
+ return +operand
112
+ raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
113
+
114
+ def visit_Name(self, node: ast.Name) -> Any:
115
+ """Handle variable names."""
116
+ name = node.id
117
+ # Built-in constants
118
+ if name == "True":
119
+ return True
120
+ if name == "False":
121
+ return False
122
+ if name == "None":
123
+ return None
124
+ # Context variables
125
+ if name in self.context:
126
+ return self.context[name]
127
+ raise ValueError(f"Unknown variable: {name}")
128
+
129
+ def visit_Constant(self, node: ast.Constant) -> Any:
130
+ """Handle literal values (strings, numbers, booleans, None)."""
131
+ return node.value
132
+
133
+ def visit_Call(self, node: ast.Call) -> Any:
134
+ """Handle function calls (only allowed functions)."""
135
+ # Get function name
136
+ if isinstance(node.func, ast.Name):
137
+ func_name = node.func.id
138
+ elif isinstance(node.func, ast.Attribute):
139
+ # Handle method calls like tool_input.get('key')
140
+ obj = self.visit(node.func.value)
141
+ method_name = node.func.attr
142
+ if method_name == "get" and isinstance(obj, dict):
143
+ args = [self.visit(arg) for arg in node.args]
144
+ return obj.get(*args)
145
+ raise ValueError(f"Unsupported method call: {method_name}")
146
+ else:
147
+ raise ValueError(f"Unsupported call type: {type(node.func).__name__}")
148
+
149
+ # Check if function is allowed
150
+ if func_name not in self.allowed_funcs:
151
+ raise ValueError(f"Function not allowed: {func_name}")
152
+
153
+ # Evaluate arguments
154
+ args = [self.visit(arg) for arg in node.args]
155
+ kwargs = {kw.arg: self.visit(kw.value) for kw in node.keywords if kw.arg}
156
+
157
+ return self.allowed_funcs[func_name](*args, **kwargs)
158
+
159
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
160
+ """Handle attribute access (e.g., obj.attr)."""
161
+ obj = self.visit(node.value)
162
+ attr = node.attr
163
+ if isinstance(obj, dict):
164
+ # Allow dict-style attribute access for convenience
165
+ if attr in obj:
166
+ return obj[attr]
167
+ raise ValueError(f"Key not found: {attr}")
168
+ if hasattr(obj, attr):
169
+ return getattr(obj, attr)
170
+ raise ValueError(f"Attribute not found: {attr}")
171
+
172
+ def visit_Subscript(self, node: ast.Subscript) -> Any:
173
+ """Handle subscript access (e.g., obj['key'] or obj[0])."""
174
+ obj = self.visit(node.value)
175
+ key = self.visit(node.slice)
176
+ try:
177
+ return obj[key]
178
+ except (KeyError, IndexError, TypeError) as e:
179
+ raise ValueError(f"Subscript access failed: {e}") from e
180
+
181
+ def generic_visit(self, node: ast.AST) -> Any:
182
+ """Reject any unsupported AST nodes."""
183
+ raise ValueError(f"Unsupported expression type: {type(node).__name__}")
@@ -137,3 +137,47 @@ def switch_mode(mode: str | None = None) -> dict[str, Any]:
137
137
  )
138
138
 
139
139
  return {"inject_context": message, "mode_switch": mode}
140
+
141
+
142
+ # --- ActionHandler-compatible wrappers ---
143
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
144
+
145
+ if __name__ != "__main__":
146
+ from typing import TYPE_CHECKING
147
+
148
+ if TYPE_CHECKING:
149
+ from gobby.workflows.actions import ActionContext
150
+
151
+
152
+ async def handle_start_new_session(
153
+ context: "ActionContext", **kwargs: Any
154
+ ) -> dict[str, Any] | None:
155
+ """ActionHandler wrapper for start_new_session."""
156
+ import asyncio
157
+
158
+ return await asyncio.to_thread(
159
+ start_new_session,
160
+ session_manager=context.session_manager,
161
+ session_id=context.session_id,
162
+ command=kwargs.get("command"),
163
+ args=kwargs.get("args"),
164
+ prompt=kwargs.get("prompt"),
165
+ cwd=kwargs.get("cwd"),
166
+ )
167
+
168
+
169
+ async def handle_mark_session_status(
170
+ context: "ActionContext", **kwargs: Any
171
+ ) -> dict[str, Any] | None:
172
+ """ActionHandler wrapper for mark_session_status."""
173
+ return mark_session_status(
174
+ session_manager=context.session_manager,
175
+ session_id=context.session_id,
176
+ status=kwargs.get("status"),
177
+ target=kwargs.get("target", "current_session"),
178
+ )
179
+
180
+
181
+ async def handle_switch_mode(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
182
+ """ActionHandler wrapper for switch_mode."""
183
+ return switch_mode(kwargs.get("mode"))
@@ -4,8 +4,12 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle workflow state persistence and variable management.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from gobby.workflows.actions import ActionContext
9
13
 
10
14
  logger = logging.getLogger(__name__)
11
15
 
@@ -121,3 +125,58 @@ def mark_loop_complete(state: Any) -> dict[str, Any]:
121
125
  state.variables = {}
122
126
  state.variables["stop_reason"] = "completed"
123
127
  return {"loop_marked_complete": True}
128
+
129
+
130
+ # --- ActionHandler-compatible wrappers ---
131
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
132
+
133
+
134
+ async def handle_load_workflow_state(
135
+ context: "ActionContext", **kwargs: Any
136
+ ) -> dict[str, Any] | None:
137
+ """ActionHandler wrapper for load_workflow_state."""
138
+ return await asyncio.to_thread(
139
+ load_workflow_state, context.db, context.session_id, context.state
140
+ )
141
+
142
+
143
+ async def handle_save_workflow_state(
144
+ context: "ActionContext", **kwargs: Any
145
+ ) -> dict[str, Any] | None:
146
+ """ActionHandler wrapper for save_workflow_state."""
147
+ return await asyncio.to_thread(save_workflow_state, context.db, context.state)
148
+
149
+
150
+ async def handle_set_variable(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
151
+ """ActionHandler wrapper for set_variable.
152
+
153
+ Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
154
+ """
155
+ value = kwargs.get("value")
156
+
157
+ # Render template if value contains Jinja2 syntax
158
+ if isinstance(value, str) and "{{" in value:
159
+ template_context = {
160
+ "variables": context.state.variables or {},
161
+ "state": context.state,
162
+ }
163
+ if context.template_engine:
164
+ value = context.template_engine.render(value, template_context)
165
+ else:
166
+ logger.warning("handle_set_variable: template_engine is None, skipping template render")
167
+
168
+ return set_variable(context.state, kwargs.get("name"), value)
169
+
170
+
171
+ async def handle_increment_variable(
172
+ context: "ActionContext", **kwargs: Any
173
+ ) -> dict[str, Any] | None:
174
+ """ActionHandler wrapper for increment_variable."""
175
+ return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
176
+
177
+
178
+ async def handle_mark_loop_complete(
179
+ context: "ActionContext", **kwargs: Any
180
+ ) -> dict[str, Any] | None:
181
+ """ActionHandler wrapper for mark_loop_complete."""
182
+ return mark_loop_complete(context.state)