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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +45 -2
- gobby/hooks/hook_manager.py +2 -2
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +2 -0
- gobby/mcp_proxy/registries.py +1 -4
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -343
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/skills/parser.py +30 -2
- gobby/storage/migrations.py +159 -372
- gobby/storage/sessions.py +43 -7
- gobby/storage/skills.py +37 -4
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +80 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +94 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
-
"
|
|
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,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
|
+
)
|