gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  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/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -348,6 +348,116 @@ class ConditionEvaluator:
348
348
 
349
349
  allowed_globals["mcp_called"] = _mcp_called
350
350
 
351
+ def _mcp_result_is_null(server: str, tool: str) -> bool:
352
+ """Check if MCP tool result is null/missing.
353
+
354
+ Used in workflow conditions like:
355
+ when: "mcp_result_is_null('gobby-tasks', 'suggest_next_task')"
356
+
357
+ Args:
358
+ server: MCP server name
359
+ tool: Tool name
360
+
361
+ Returns:
362
+ True if the result is null/missing, False if result exists.
363
+ """
364
+ variables = context.get("variables", {})
365
+ if isinstance(variables, dict):
366
+ mcp_results = variables.get("mcp_results", {})
367
+ else:
368
+ mcp_results = getattr(variables, "mcp_results", {})
369
+
370
+ if not isinstance(mcp_results, dict):
371
+ return True # No results means null
372
+
373
+ server_results = mcp_results.get(server, {})
374
+ if not isinstance(server_results, dict):
375
+ return True
376
+
377
+ result = server_results.get(tool)
378
+ return result is None
379
+
380
+ allowed_globals["mcp_result_is_null"] = _mcp_result_is_null
381
+
382
+ def _mcp_failed(server: str, tool: str) -> bool:
383
+ """Check if MCP tool call failed.
384
+
385
+ Used in workflow conditions like:
386
+ when: "mcp_failed('gobby-agents', 'spawn_agent')"
387
+
388
+ Args:
389
+ server: MCP server name
390
+ tool: Tool name
391
+
392
+ Returns:
393
+ True if the result exists and indicates failure.
394
+ """
395
+ variables = context.get("variables", {})
396
+ if isinstance(variables, dict):
397
+ mcp_results = variables.get("mcp_results", {})
398
+ else:
399
+ mcp_results = getattr(variables, "mcp_results", {})
400
+
401
+ if not isinstance(mcp_results, dict):
402
+ return False # No results means we can't determine failure
403
+
404
+ server_results = mcp_results.get(server, {})
405
+ if not isinstance(server_results, dict):
406
+ return False
407
+
408
+ result = server_results.get(tool)
409
+ if result is None:
410
+ return False
411
+
412
+ # Check for failure indicators
413
+ if isinstance(result, dict):
414
+ if result.get("success") is False:
415
+ return True
416
+ if result.get("error"):
417
+ return True
418
+ if result.get("status") == "failed":
419
+ return True
420
+ return False
421
+
422
+ allowed_globals["mcp_failed"] = _mcp_failed
423
+
424
+ def _mcp_result_has(server: str, tool: str, field: str, value: Any) -> bool:
425
+ """Check if MCP tool result has a specific field value.
426
+
427
+ Used in workflow conditions like:
428
+ when: "mcp_result_has('gobby-tasks', 'wait_for_task', 'timed_out', True)"
429
+
430
+ Args:
431
+ server: MCP server name
432
+ tool: Tool name
433
+ field: Field name to check
434
+ value: Expected value (supports bool, str, int, float)
435
+
436
+ Returns:
437
+ True if the field equals the expected value.
438
+ """
439
+ variables = context.get("variables", {})
440
+ if isinstance(variables, dict):
441
+ mcp_results = variables.get("mcp_results", {})
442
+ else:
443
+ mcp_results = getattr(variables, "mcp_results", {})
444
+
445
+ if not isinstance(mcp_results, dict):
446
+ return False
447
+
448
+ server_results = mcp_results.get(server, {})
449
+ if not isinstance(server_results, dict):
450
+ return False
451
+
452
+ result = server_results.get(tool)
453
+ if not isinstance(result, dict):
454
+ return False
455
+
456
+ actual_value = result.get(field)
457
+ return bool(actual_value == value)
458
+
459
+ allowed_globals["mcp_result_has"] = _mcp_result_has
460
+
351
461
  # eval used with restricted allowed_globals for workflow conditions
352
462
  # nosec B307: eval is intentional here for DSL evaluation with
353
463
  # restricted globals (__builtins__={}) and controlled workflow conditions
@@ -4,8 +4,15 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These are pure utility functions with no ActionContext dependency.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import logging
8
10
  import subprocess # nosec B404 - subprocess needed for git commands
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.storage.session_tasks import SessionTaskManager
15
+ from gobby.storage.sessions import LocalSessionManager
9
16
 
10
17
  logger = logging.getLogger(__name__)
11
18
 
@@ -94,3 +101,102 @@ def get_file_changes() -> str:
94
101
 
95
102
  except Exception:
96
103
  return "Unable to determine file changes"
104
+
105
+
106
+ def get_dirty_files(project_path: str | None = None) -> set[str]:
107
+ """
108
+ Get the set of dirty files from git status --porcelain.
109
+
110
+ Excludes .gobby/ files from the result.
111
+
112
+ Args:
113
+ project_path: Path to the project directory
114
+
115
+ Returns:
116
+ Set of dirty file paths (relative to repo root)
117
+ """
118
+ if project_path is None:
119
+ logger.warning(
120
+ "get_dirty_files: project_path is None, git status will use daemon's cwd "
121
+ "which may not be the project directory"
122
+ )
123
+
124
+ try:
125
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
126
+ ["git", "status", "--porcelain"],
127
+ cwd=project_path,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=10,
131
+ )
132
+
133
+ if result.returncode != 0:
134
+ logger.warning(f"get_dirty_files: git status failed: {result.stderr}")
135
+ return set()
136
+
137
+ dirty_files = set()
138
+ # Split by newline first, don't strip() the whole string as it removes
139
+ # the leading space from git status format (e.g., " M file.py")
140
+ for line in result.stdout.split("\n"):
141
+ line = line.rstrip() # Remove trailing whitespace only
142
+ if not line:
143
+ continue
144
+ # Format is "XY filename" or "XY filename -> newname" for renames
145
+ # Skip the status prefix (first 3 chars: 2 status chars + space)
146
+ filepath = line[3:].split(" -> ")[0] # Handle renames
147
+ # Exclude .gobby/ files
148
+ if not filepath.startswith(".gobby/"):
149
+ dirty_files.add(filepath)
150
+
151
+ return dirty_files
152
+
153
+ except subprocess.TimeoutExpired:
154
+ logger.warning("get_dirty_files: git status timed out")
155
+ return set()
156
+ except FileNotFoundError:
157
+ logger.warning("get_dirty_files: git not found")
158
+ return set()
159
+ except Exception as e:
160
+ logger.error(f"get_dirty_files: Error running git status: {e}")
161
+ return set()
162
+
163
+
164
+ def get_task_session_liveness(
165
+ task_id: str,
166
+ session_task_manager: SessionTaskManager | None,
167
+ session_manager: LocalSessionManager | None,
168
+ exclude_session_id: str | None = None,
169
+ ) -> bool:
170
+ """
171
+ Check if a task is currently being worked on by an active session.
172
+
173
+ Args:
174
+ task_id: The task ID to check
175
+ session_task_manager: Manager to look up session-task links
176
+ session_manager: Manager to check session status
177
+ exclude_session_id: ID of session to exclude from check (e.g. current one)
178
+
179
+ Returns:
180
+ True if an active session (status='active') is linked to this task.
181
+ """
182
+ if not session_task_manager or not session_manager:
183
+ return False
184
+
185
+ try:
186
+ # Get all sessions linked to this task
187
+ linked_sessions = session_task_manager.get_task_sessions(task_id)
188
+
189
+ for link in linked_sessions:
190
+ session_id = link.get("session_id")
191
+ if not session_id or session_id == exclude_session_id:
192
+ continue
193
+
194
+ # Check if session is truly active
195
+ session = session_manager.get(session_id)
196
+ if session and session.status == "active":
197
+ return True
198
+
199
+ return False
200
+ except Exception as e:
201
+ logger.warning(f"get_task_session_liveness: Error checking liveness for {task_id}: {e}")
202
+ return False
gobby/workflows/hooks.py CHANGED
@@ -167,3 +167,44 @@ class WorkflowHookHandler:
167
167
  except Exception as e:
168
168
  logger.error(f"Error handling lifecycle workflow: {e}", exc_info=True)
169
169
  return HookResponse(decision="allow")
170
+
171
+ def activate_workflow(
172
+ self,
173
+ workflow_name: str,
174
+ session_id: str,
175
+ project_path: str | None = None,
176
+ variables: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """
179
+ Activate a step-based workflow for a session.
180
+
181
+ This is used during session startup for terminal-mode agents that have
182
+ a workflow_name set. It's a synchronous wrapper around the engine's
183
+ activate_workflow method.
184
+
185
+ Args:
186
+ workflow_name: Name of the workflow to activate
187
+ session_id: Session ID to activate for
188
+ project_path: Optional project path for workflow discovery
189
+ variables: Optional initial variables to merge with workflow defaults
190
+
191
+ Returns:
192
+ Dict with success status and workflow info
193
+ """
194
+ if not self._enabled:
195
+ return {"success": False, "error": "Workflow engine is disabled"}
196
+
197
+ from pathlib import Path
198
+
199
+ path = Path(project_path) if project_path else None
200
+
201
+ try:
202
+ return self.engine.activate_workflow(
203
+ workflow_name=workflow_name,
204
+ session_id=session_id,
205
+ project_path=path,
206
+ variables=variables,
207
+ )
208
+ except Exception as e:
209
+ logger.error(f"Error activating workflow: {e}", exc_info=True)
210
+ return {"success": False, "error": str(e)}
@@ -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
+ )
@@ -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
+ )
@@ -205,6 +205,17 @@ async def memory_recall_relevant(
205
205
  # Filter out memories that have already been injected in this session
206
206
  new_memories = [m for m in memories if m.id not in injected_ids]
207
207
 
208
+ # Deduplicate by content to avoid showing same content with different IDs
209
+ # (can happen when same content was stored with different project_ids)
210
+ seen_content: set[str] = set()
211
+ unique_memories = []
212
+ for m in new_memories:
213
+ normalized = m.content.strip()
214
+ if normalized not in seen_content:
215
+ seen_content.add(normalized)
216
+ unique_memories.append(m)
217
+ new_memories = unique_memories
218
+
208
219
  if not new_memories:
209
220
  logger.debug(
210
221
  f"memory_recall_relevant: All {len(memories)} memories already injected, skipping"
@@ -344,3 +355,83 @@ async def memory_extract(
344
355
  except Exception as e:
345
356
  logger.error(f"memory_extract: Failed: {e}", exc_info=True)
346
357
  return {"error": str(e)}
358
+
359
+
360
+ # --- ActionHandler-compatible wrappers ---
361
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
362
+
363
+ if __name__ != "__main__":
364
+ from typing import TYPE_CHECKING
365
+
366
+ if TYPE_CHECKING:
367
+ from gobby.workflows.actions import ActionContext
368
+
369
+
370
+ async def handle_memory_sync_import(
371
+ context: "ActionContext", **kwargs: Any
372
+ ) -> dict[str, Any] | None:
373
+ """ActionHandler wrapper for memory_sync_import."""
374
+ return await memory_sync_import(context.memory_sync_manager)
375
+
376
+
377
+ async def handle_memory_sync_export(
378
+ context: "ActionContext", **kwargs: Any
379
+ ) -> dict[str, Any] | None:
380
+ """ActionHandler wrapper for memory_sync_export."""
381
+ return await memory_sync_export(context.memory_sync_manager)
382
+
383
+
384
+ async def handle_memory_save(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
385
+ """ActionHandler wrapper for memory_save."""
386
+ return await memory_save(
387
+ memory_manager=context.memory_manager,
388
+ session_manager=context.session_manager,
389
+ session_id=context.session_id,
390
+ content=kwargs.get("content"),
391
+ memory_type=kwargs.get("memory_type", "fact"),
392
+ importance=kwargs.get("importance", 0.5),
393
+ tags=kwargs.get("tags"),
394
+ project_id=kwargs.get("project_id"),
395
+ )
396
+
397
+
398
+ async def handle_memory_recall_relevant(
399
+ context: "ActionContext", **kwargs: Any
400
+ ) -> dict[str, Any] | None:
401
+ """ActionHandler wrapper for memory_recall_relevant."""
402
+ prompt_text = None
403
+ if context.event_data:
404
+ # Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
405
+ prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
406
+
407
+ return await memory_recall_relevant(
408
+ memory_manager=context.memory_manager,
409
+ session_manager=context.session_manager,
410
+ session_id=context.session_id,
411
+ prompt_text=prompt_text,
412
+ project_id=kwargs.get("project_id"),
413
+ limit=kwargs.get("limit", 5),
414
+ min_importance=kwargs.get("min_importance", 0.3),
415
+ state=context.state,
416
+ )
417
+
418
+
419
+ async def handle_reset_memory_injection_tracking(
420
+ context: "ActionContext", **kwargs: Any
421
+ ) -> dict[str, Any] | None:
422
+ """ActionHandler wrapper for reset_memory_injection_tracking."""
423
+ return reset_memory_injection_tracking(state=context.state)
424
+
425
+
426
+ async def handle_memory_extract(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
427
+ """ActionHandler wrapper for memory_extract."""
428
+ return await memory_extract(
429
+ session_manager=context.session_manager,
430
+ session_id=context.session_id,
431
+ llm_service=context.llm_service,
432
+ memory_manager=context.memory_manager,
433
+ transcript_processor=context.transcript_processor,
434
+ min_importance=kwargs.get("min_importance", 0.7),
435
+ max_memories=kwargs.get("max_memories", 5),
436
+ dry_run=kwargs.get("dry_run", False),
437
+ )
@@ -0,0 +1,191 @@
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 visit_List(self, node: ast.List) -> list[Any]:
182
+ """Handle list literals (e.g., ['a', 'b', 'c'])."""
183
+ return [self.visit(elt) for elt in node.elts]
184
+
185
+ def visit_Tuple(self, node: ast.Tuple) -> tuple[Any, ...]:
186
+ """Handle tuple literals (e.g., ('a', 'b', 'c'))."""
187
+ return tuple(self.visit(elt) for elt in node.elts)
188
+
189
+ def generic_visit(self, node: ast.AST) -> Any:
190
+ """Reject any unsupported AST nodes."""
191
+ 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"))