gobby 0.2.6__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -53,7 +53,7 @@ def validate_commit_requirements(
53
53
  can_close=False,
54
54
  error_type="no_commits_linked",
55
55
  message=(
56
- "A commit is required before closing this task.\n\n"
56
+ "\nA commit is required before closing this task.\n\n"
57
57
  "**Normal flow:**\n"
58
58
  '1. Commit your changes: git commit -m "[#N] description"\n'
59
59
  '2. Close with commit_sha: close_task(task_id="#N", commit_sha="<sha>")\n\n'
@@ -22,13 +22,9 @@ from typing import TYPE_CHECKING, Any, Literal, cast
22
22
 
23
23
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
24
24
  from gobby.utils.project_context import get_project_context
25
- from gobby.workflows.definitions import WorkflowState
26
- from gobby.workflows.loader import WorkflowLoader
27
- from gobby.workflows.state_manager import WorkflowStateManager
28
25
  from gobby.worktrees.git import WorktreeGitManager
29
26
 
30
27
  if TYPE_CHECKING:
31
- from gobby.agents.runner import AgentRunner
32
28
  from gobby.storage.worktrees import LocalWorktreeManager
33
29
  from gobby.worktrees.git import WorktreeGitManager
34
30
 
@@ -291,7 +287,6 @@ def create_worktrees_registry(
291
287
  worktree_storage: LocalWorktreeManager,
292
288
  git_manager: WorktreeGitManager | None = None,
293
289
  project_id: str | None = None,
294
- agent_runner: AgentRunner | None = None,
295
290
  ) -> InternalToolRegistry:
296
291
  """
297
292
  Create a worktree tool registry with all worktree-related tools.
@@ -300,7 +295,6 @@ def create_worktrees_registry(
300
295
  worktree_storage: LocalWorktreeManager for database operations.
301
296
  git_manager: WorktreeGitManager for git operations.
302
297
  project_id: Default project ID for operations.
303
- agent_runner: AgentRunner for spawning agents in worktrees.
304
298
 
305
299
  Returns:
306
300
  InternalToolRegistry with all worktree tools registered.
@@ -929,341 +923,4 @@ def create_worktrees_registry(
929
923
 
930
924
  return {}
931
925
 
932
- @registry.tool(
933
- name="spawn_agent_in_worktree",
934
- description="Create a worktree and spawn an agent in it.",
935
- )
936
- async def spawn_agent_in_worktree(
937
- prompt: str,
938
- branch_name: str,
939
- base_branch: str = "main",
940
- task_id: str | None = None,
941
- parent_session_id: str | None = None,
942
- mode: str = "terminal", # Note: in_process mode is not supported
943
- terminal: str = "auto",
944
- provider: Literal["claude", "gemini", "codex", "antigravity"] = "claude",
945
- model: str | None = None,
946
- workflow: str | None = None,
947
- timeout: float = 120.0,
948
- max_turns: int = 10,
949
- project_path: str | None = None,
950
- ) -> dict[str, Any]:
951
- """
952
- Create a worktree and spawn an agent to work in it.
953
-
954
- This combines worktree creation with agent spawning for isolated development.
955
-
956
- Args:
957
- prompt: The task/prompt for the agent.
958
- branch_name: Name for the new branch/worktree.
959
- base_branch: Branch to base the worktree on (default: main).
960
- task_id: Optional task ID to link to this worktree.
961
- parent_session_id: Parent session ID for context.
962
- mode: Execution mode (terminal, embedded, headless). Note: in_process is not supported.
963
- terminal: Terminal for terminal/embedded modes (auto, ghostty, etc.).
964
- provider: LLM provider (claude, gemini, etc.).
965
- model: Optional model override.
966
- workflow: Workflow name to execute.
967
- timeout: Execution timeout in seconds (default: 120).
968
- max_turns: Maximum turns (default: 10).
969
- project_path: Path to project directory (pass cwd from CLI).
970
-
971
- Returns:
972
- Dict with worktree_id, run_id, and status.
973
- """
974
- if agent_runner is None:
975
- return {
976
- "success": False,
977
- "error": "Agent runner not configured. Cannot spawn agent.",
978
- }
979
-
980
- # Resolve project context
981
- resolved_git_mgr, resolved_project_id, error = _resolve_project_context(
982
- project_path, git_manager, project_id
983
- )
984
- if error:
985
- return {"success": False, "error": error}
986
-
987
- # Type narrowing: if no error, these are guaranteed non-None
988
- if resolved_git_mgr is None or resolved_project_id is None:
989
- raise RuntimeError("Git manager or project ID unexpectedly None")
990
-
991
- if parent_session_id is None:
992
- return {
993
- "success": False,
994
- "error": "parent_session_id is required for agent spawning.",
995
- }
996
-
997
- # Handle mode aliases and validation
998
- # "interactive" is an alias for "terminal" mode
999
- if mode == "interactive":
1000
- mode = "terminal"
1001
-
1002
- valid_modes = ["terminal", "embedded", "headless"]
1003
- if mode not in valid_modes:
1004
- return {
1005
- "success": False,
1006
- "error": (
1007
- f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)} (or 'interactive' as alias for 'terminal'). "
1008
- f"Note: 'in_process' mode is not supported for spawn_agent_in_worktree."
1009
- ),
1010
- }
1011
-
1012
- # Normalize terminal parameter to lowercase for enum compatibility
1013
- # (TerminalType enum values are lowercase, e.g., "terminal.app" not "Terminal.app")
1014
- if isinstance(terminal, str):
1015
- terminal = terminal.lower()
1016
-
1017
- # Default to 'worktree-agent' workflow if not specified
1018
- # This workflow restricts tools available to spawned agents in worktrees
1019
- if workflow is None:
1020
- workflow = "worktree-agent"
1021
-
1022
- # Validate workflow (reject lifecycle workflows)
1023
- if workflow:
1024
- workflow_loader = WorkflowLoader()
1025
- is_valid, error_msg = workflow_loader.validate_workflow_for_agent(
1026
- workflow, project_path=project_path
1027
- )
1028
- if not is_valid:
1029
- return {
1030
- "success": False,
1031
- "error": error_msg,
1032
- }
1033
-
1034
- # Check if worktree already exists for this branch
1035
- existing = worktree_storage.get_by_branch(resolved_project_id, branch_name)
1036
- if existing:
1037
- # Use existing worktree
1038
- worktree = existing
1039
- logger.info(f"Using existing worktree for branch '{branch_name}'")
1040
- else:
1041
- # Generate worktree path in temp directory
1042
- project_name = Path(resolved_git_mgr.repo_path).name
1043
- worktree_path = _generate_worktree_path(branch_name, project_name)
1044
-
1045
- # Create git worktree
1046
- result = resolved_git_mgr.create_worktree(
1047
- worktree_path=worktree_path,
1048
- branch_name=branch_name,
1049
- base_branch=base_branch,
1050
- create_branch=True,
1051
- )
1052
-
1053
- if not result.success:
1054
- return {
1055
- "success": False,
1056
- "error": result.error or "Failed to create git worktree",
1057
- }
1058
-
1059
- # Record in database
1060
- worktree = worktree_storage.create(
1061
- project_id=resolved_project_id,
1062
- branch_name=branch_name,
1063
- worktree_path=worktree_path,
1064
- base_branch=base_branch,
1065
- task_id=task_id,
1066
- )
1067
-
1068
- # Copy project.json and install provider hooks
1069
- _copy_project_json_to_worktree(resolved_git_mgr.repo_path, worktree.worktree_path)
1070
- _install_provider_hooks(provider, worktree.worktree_path)
1071
-
1072
- # Check spawn depth limit
1073
- can_spawn, reason, _depth = agent_runner.can_spawn(parent_session_id)
1074
- if not can_spawn:
1075
- return {
1076
- "success": False,
1077
- "error": reason,
1078
- "worktree_id": worktree.id,
1079
- }
1080
-
1081
- # Import AgentConfig and get machine_id
1082
- from gobby.agents.runner import AgentConfig
1083
- from gobby.utils.machine_id import get_machine_id
1084
-
1085
- # Auto-detect machine_id if not provided
1086
- machine_id = get_machine_id()
1087
-
1088
- # Create agent config with worktree
1089
- config = AgentConfig(
1090
- prompt=prompt,
1091
- parent_session_id=parent_session_id,
1092
- project_id=resolved_project_id,
1093
- machine_id=machine_id,
1094
- source=provider,
1095
- workflow=workflow,
1096
- task=task_id,
1097
- session_context="summary_markdown",
1098
- mode=mode,
1099
- terminal=terminal,
1100
- worktree_id=worktree.id,
1101
- provider=provider,
1102
- model=model,
1103
- max_turns=max_turns,
1104
- timeout=timeout,
1105
- project_path=worktree.worktree_path,
1106
- )
1107
-
1108
- # For terminal/embedded/headless modes, use prepare_run + spawner
1109
- # (runner.run() is only for in_process mode)
1110
- from gobby.llm.executor import AgentResult
1111
-
1112
- prepare_result = agent_runner.prepare_run(config)
1113
- if isinstance(prepare_result, AgentResult):
1114
- # prepare_run returns AgentResult on error
1115
- return {
1116
- "success": False,
1117
- "worktree_id": worktree.id,
1118
- "worktree_path": worktree.worktree_path,
1119
- "branch_name": worktree.branch_name,
1120
- "error": prepare_result.error,
1121
- }
1122
-
1123
- # Successfully prepared - we have context with session and run
1124
- context = prepare_result
1125
-
1126
- if context.session is None or context.run is None:
1127
- return {
1128
- "success": False,
1129
- "worktree_id": worktree.id,
1130
- "error": "Internal error: context missing session or run after prepare_run",
1131
- }
1132
-
1133
- child_session = context.session
1134
- agent_run = context.run
1135
-
1136
- # Claim worktree for the child session
1137
- worktree_storage.claim(worktree.id, child_session.id)
1138
-
1139
- # Pre-save workflow state with session_task if task_id is provided
1140
- # This ensures suggest_next_task() will scope to this task's subtasks
1141
- if task_id and workflow:
1142
- try:
1143
- workflow_state_manager = WorkflowStateManager(worktree_storage.db)
1144
- initial_state = WorkflowState(
1145
- session_id=child_session.id,
1146
- workflow_name=workflow,
1147
- step="", # Will be set when workflow actually starts
1148
- variables={"session_task": task_id},
1149
- )
1150
- workflow_state_manager.save_state(initial_state)
1151
- logger.debug(
1152
- f"Pre-saved workflow state for session {child_session.id} "
1153
- f"with session_task={task_id}"
1154
- )
1155
- except Exception as e:
1156
- logger.warning(f"Failed to pre-save workflow state: {e}")
1157
- # Continue anyway - this is an optimization, not a requirement
1158
-
1159
- # Build enhanced prompt with worktree context
1160
- # This helps the agent understand it's in an isolated worktree, not the main repo
1161
- enhanced_prompt = _build_worktree_context_prompt(
1162
- original_prompt=prompt,
1163
- worktree_path=worktree.worktree_path,
1164
- branch_name=worktree.branch_name,
1165
- task_id=task_id,
1166
- main_repo_path=str(resolved_git_mgr.repo_path),
1167
- )
1168
-
1169
- # Spawn in terminal using TerminalSpawner
1170
- if mode == "terminal":
1171
- from gobby.agents.spawn import TerminalSpawner
1172
-
1173
- terminal_spawner = TerminalSpawner()
1174
- terminal_result = terminal_spawner.spawn_agent(
1175
- cli=provider, # claude, gemini, codex
1176
- cwd=worktree.worktree_path,
1177
- session_id=child_session.id,
1178
- parent_session_id=parent_session_id,
1179
- agent_run_id=agent_run.id,
1180
- project_id=resolved_project_id,
1181
- workflow_name=workflow,
1182
- agent_depth=child_session.agent_depth,
1183
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1184
- terminal=terminal,
1185
- prompt=enhanced_prompt,
1186
- )
1187
-
1188
- if not terminal_result.success:
1189
- return {
1190
- "success": False,
1191
- "worktree_id": worktree.id,
1192
- "worktree_path": worktree.worktree_path,
1193
- "branch_name": worktree.branch_name,
1194
- "run_id": agent_run.id,
1195
- "child_session_id": child_session.id,
1196
- "error": terminal_result.error or terminal_result.message,
1197
- }
1198
-
1199
- return {
1200
- "success": True,
1201
- "worktree_id": worktree.id,
1202
- "worktree_path": worktree.worktree_path,
1203
- "branch_name": worktree.branch_name,
1204
- "run_id": agent_run.id,
1205
- "child_session_id": child_session.id,
1206
- "status": "pending",
1207
- "message": f"Agent spawned in {terminal_result.terminal_type} (PID: {terminal_result.pid})",
1208
- "terminal_type": terminal_result.terminal_type,
1209
- "pid": terminal_result.pid,
1210
- }
1211
-
1212
- elif mode == "embedded":
1213
- from gobby.agents.spawn import EmbeddedSpawner
1214
-
1215
- embedded_spawner = EmbeddedSpawner()
1216
- embedded_result = embedded_spawner.spawn_agent(
1217
- cli=provider,
1218
- cwd=worktree.worktree_path,
1219
- session_id=child_session.id,
1220
- parent_session_id=parent_session_id,
1221
- agent_run_id=agent_run.id,
1222
- project_id=resolved_project_id,
1223
- workflow_name=workflow,
1224
- agent_depth=child_session.agent_depth,
1225
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1226
- prompt=enhanced_prompt,
1227
- )
1228
-
1229
- return {
1230
- "success": embedded_result.success,
1231
- "worktree_id": worktree.id,
1232
- "worktree_path": worktree.worktree_path,
1233
- "branch_name": worktree.branch_name,
1234
- "run_id": agent_run.id,
1235
- "child_session_id": child_session.id,
1236
- "status": "pending" if embedded_result.success else "error",
1237
- "error": embedded_result.error if not embedded_result.success else None,
1238
- }
1239
-
1240
- else: # headless
1241
- from gobby.agents.spawn import HeadlessSpawner
1242
-
1243
- headless_spawner = HeadlessSpawner()
1244
- headless_result = headless_spawner.spawn_agent(
1245
- cli=provider,
1246
- cwd=worktree.worktree_path,
1247
- session_id=child_session.id,
1248
- parent_session_id=parent_session_id,
1249
- agent_run_id=agent_run.id,
1250
- project_id=resolved_project_id,
1251
- workflow_name=workflow,
1252
- agent_depth=child_session.agent_depth,
1253
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1254
- prompt=enhanced_prompt,
1255
- )
1256
-
1257
- return {
1258
- "success": headless_result.success,
1259
- "worktree_id": worktree.id,
1260
- "worktree_path": worktree.worktree_path,
1261
- "branch_name": worktree.branch_name,
1262
- "run_id": agent_run.id,
1263
- "child_session_id": child_session.id,
1264
- "status": "pending" if headless_result.success else "error",
1265
- "pid": headless_result.pid if headless_result.success else None,
1266
- "error": headless_result.error if not headless_result.success else None,
1267
- }
1268
-
1269
926
  return registry
@@ -0,0 +1,5 @@
1
+ """Memory ingestion components for multimodal content."""
2
+
3
+ from gobby.memory.ingestion.multimodal import MultimodalIngestor
4
+
5
+ __all__ = ["MultimodalIngestor"]
@@ -0,0 +1,221 @@
1
+ """Multimodal content ingestion for memory system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import mimetypes
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gobby.memory.protocol import MediaAttachment
11
+ from gobby.storage.memories import Memory
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.llm.service import LLMService
15
+ from gobby.memory.protocol import MemoryBackendProtocol
16
+ from gobby.storage.memories import LocalMemoryManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class MultimodalIngestor:
22
+ """
23
+ Handles ingestion of multimodal content (images, screenshots) into memory.
24
+
25
+ Extracts image handling from MemoryManager to provide focused
26
+ multimodal processing capabilities.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ storage: LocalMemoryManager,
32
+ backend: MemoryBackendProtocol,
33
+ llm_service: LLMService | None = None,
34
+ ):
35
+ """
36
+ Initialize the multimodal ingestor.
37
+
38
+ Args:
39
+ storage: Memory storage manager for persistence
40
+ backend: Memory backend protocol for creating records
41
+ llm_service: LLM service for image description
42
+ """
43
+ self._storage = storage
44
+ self._backend = backend
45
+ self._llm_service = llm_service
46
+
47
+ @property
48
+ def llm_service(self) -> LLMService | None:
49
+ """Get the LLM service for image description."""
50
+ return self._llm_service
51
+
52
+ @llm_service.setter
53
+ def llm_service(self, service: LLMService | None) -> None:
54
+ """Set the LLM service for image description."""
55
+ self._llm_service = service
56
+
57
+ async def remember_with_image(
58
+ self,
59
+ image_path: str,
60
+ context: str | None = None,
61
+ memory_type: str = "fact",
62
+ importance: float = 0.5,
63
+ project_id: str | None = None,
64
+ source_type: str = "user",
65
+ source_session_id: str | None = None,
66
+ tags: list[str] | None = None,
67
+ ) -> Memory:
68
+ """
69
+ Store a memory with an image attachment.
70
+
71
+ Uses the configured LLM provider to generate a description of the image,
72
+ then stores the memory with the description as content and the image
73
+ as a media attachment.
74
+
75
+ Args:
76
+ image_path: Path to the image file
77
+ context: Optional context to guide the image description
78
+ memory_type: Type of memory (fact, preference, etc)
79
+ importance: 0.0-1.0 importance score
80
+ project_id: Optional project context
81
+ source_type: Origin of memory
82
+ source_session_id: Origin session
83
+ tags: Optional tags
84
+
85
+ Returns:
86
+ The created Memory object
87
+
88
+ Raises:
89
+ ValueError: If LLM service is not configured or image not found
90
+ """
91
+ path = Path(image_path)
92
+ if not path.exists():
93
+ raise ValueError(f"Image not found: {image_path}")
94
+
95
+ # Get LLM provider for image description
96
+ if not self._llm_service:
97
+ raise ValueError(
98
+ "LLM service not configured. Pass llm_service to MemoryManager "
99
+ "to enable remember_with_image."
100
+ )
101
+
102
+ provider = self._llm_service.get_default_provider()
103
+
104
+ # Generate image description
105
+ description = await provider.describe_image(image_path, context=context)
106
+
107
+ # Determine MIME type
108
+ mime_type, _ = mimetypes.guess_type(str(path))
109
+ if not mime_type:
110
+ mime_type = "application/octet-stream"
111
+
112
+ # Create media attachment
113
+ media = MediaAttachment(
114
+ media_type="image",
115
+ content_path=str(path.absolute()),
116
+ mime_type=mime_type,
117
+ description=description,
118
+ description_model=provider.provider_name,
119
+ )
120
+
121
+ # Store memory with media attachment via backend
122
+ record = await self._backend.create(
123
+ content=description,
124
+ memory_type=memory_type,
125
+ importance=importance,
126
+ project_id=project_id,
127
+ source_type=source_type,
128
+ source_session_id=source_session_id,
129
+ tags=tags,
130
+ media=[media],
131
+ )
132
+
133
+ # Return as Memory object for backward compatibility
134
+ # Note: The backend returns MemoryRecord, but we need Memory
135
+ memory = self._storage.get_memory(record.id)
136
+ if memory is not None:
137
+ return memory
138
+
139
+ # Fallback: construct Memory from MemoryRecord if storage lookup fails
140
+ # This can happen with synthetic records from failed backend calls
141
+ return Memory(
142
+ id=record.id,
143
+ content=record.content,
144
+ memory_type=record.memory_type,
145
+ created_at=record.created_at.isoformat(),
146
+ updated_at=record.updated_at.isoformat()
147
+ if record.updated_at
148
+ else record.created_at.isoformat(),
149
+ project_id=record.project_id,
150
+ source_type=record.source_type,
151
+ source_session_id=record.source_session_id,
152
+ importance=record.importance,
153
+ tags=record.tags,
154
+ )
155
+
156
+ async def remember_screenshot(
157
+ self,
158
+ screenshot_bytes: bytes,
159
+ context: str | None = None,
160
+ memory_type: str = "observation",
161
+ importance: float = 0.5,
162
+ project_id: str | None = None,
163
+ source_type: str = "user",
164
+ source_session_id: str | None = None,
165
+ tags: list[str] | None = None,
166
+ ) -> Memory:
167
+ """
168
+ Store a memory from raw screenshot bytes.
169
+
170
+ Saves the screenshot to .gobby/resources/ with a timestamp-based filename,
171
+ then delegates to remember_with_image() for LLM description and storage.
172
+
173
+ Args:
174
+ screenshot_bytes: Raw PNG screenshot bytes (from Playwright/Puppeteer)
175
+ context: Optional context to guide the image description
176
+ memory_type: Type of memory (default: "observation")
177
+ importance: 0.0-1.0 importance score
178
+ project_id: Optional project context
179
+ source_type: Origin of memory
180
+ source_session_id: Origin session
181
+ tags: Optional tags
182
+
183
+ Returns:
184
+ The created Memory object
185
+
186
+ Raises:
187
+ ValueError: If LLM service is not configured or screenshot bytes are empty
188
+ """
189
+ if not screenshot_bytes:
190
+ raise ValueError("Screenshot bytes cannot be empty")
191
+
192
+ # Determine resources directory using centralized utility
193
+ from datetime import datetime as dt
194
+
195
+ from gobby.cli.utils import get_resources_dir
196
+ from gobby.utils.project_context import get_project_context
197
+
198
+ ctx = get_project_context()
199
+ project_path = ctx.get("path") if ctx else None
200
+ resources_dir = get_resources_dir(project_path)
201
+
202
+ # Generate timestamp-based filename
203
+ timestamp = dt.now().strftime("%Y%m%d_%H%M%S_%f")
204
+ filename = f"screenshot_{timestamp}.png"
205
+ filepath = resources_dir / filename
206
+
207
+ # Write screenshot to file
208
+ filepath.write_bytes(screenshot_bytes)
209
+ logger.debug(f"Saved screenshot to {filepath}")
210
+
211
+ # Delegate to remember_with_image
212
+ return await self.remember_with_image(
213
+ image_path=str(filepath),
214
+ context=context,
215
+ memory_type=memory_type,
216
+ importance=importance,
217
+ project_id=project_id,
218
+ source_type=source_type,
219
+ source_session_id=source_session_id,
220
+ tags=tags,
221
+ )