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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- 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/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -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/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- 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/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- 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/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- 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 +239 -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 +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- 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/runner.py +13 -0
- 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 +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- 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/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -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/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -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 +217 -51
- 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.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- 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/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/workflows/state_actions.py
CHANGED
|
@@ -4,8 +4,12 @@ Extracted from actions.py as part of strangler fig decomposition.
|
|
|
4
4
|
These functions handle workflow state persistence and variable management.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import logging
|
|
8
|
-
from typing import Any
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from gobby.workflows.actions import ActionContext
|
|
9
13
|
|
|
10
14
|
logger = logging.getLogger(__name__)
|
|
11
15
|
|
|
@@ -121,3 +125,58 @@ def mark_loop_complete(state: Any) -> dict[str, Any]:
|
|
|
121
125
|
state.variables = {}
|
|
122
126
|
state.variables["stop_reason"] = "completed"
|
|
123
127
|
return {"loop_marked_complete": True}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- ActionHandler-compatible wrappers ---
|
|
131
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def handle_load_workflow_state(
|
|
135
|
+
context: "ActionContext", **kwargs: Any
|
|
136
|
+
) -> dict[str, Any] | None:
|
|
137
|
+
"""ActionHandler wrapper for load_workflow_state."""
|
|
138
|
+
return await asyncio.to_thread(
|
|
139
|
+
load_workflow_state, context.db, context.session_id, context.state
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def handle_save_workflow_state(
|
|
144
|
+
context: "ActionContext", **kwargs: Any
|
|
145
|
+
) -> dict[str, Any] | None:
|
|
146
|
+
"""ActionHandler wrapper for save_workflow_state."""
|
|
147
|
+
return await asyncio.to_thread(save_workflow_state, context.db, context.state)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def handle_set_variable(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
|
|
151
|
+
"""ActionHandler wrapper for set_variable.
|
|
152
|
+
|
|
153
|
+
Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
|
|
154
|
+
"""
|
|
155
|
+
value = kwargs.get("value")
|
|
156
|
+
|
|
157
|
+
# Render template if value contains Jinja2 syntax
|
|
158
|
+
if isinstance(value, str) and "{{" in value:
|
|
159
|
+
template_context = {
|
|
160
|
+
"variables": context.state.variables or {},
|
|
161
|
+
"state": context.state,
|
|
162
|
+
}
|
|
163
|
+
if context.template_engine:
|
|
164
|
+
value = context.template_engine.render(value, template_context)
|
|
165
|
+
else:
|
|
166
|
+
logger.warning("handle_set_variable: template_engine is None, skipping template render")
|
|
167
|
+
|
|
168
|
+
return set_variable(context.state, kwargs.get("name"), value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def handle_increment_variable(
|
|
172
|
+
context: "ActionContext", **kwargs: Any
|
|
173
|
+
) -> dict[str, Any] | None:
|
|
174
|
+
"""ActionHandler wrapper for increment_variable."""
|
|
175
|
+
return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def handle_mark_loop_complete(
|
|
179
|
+
context: "ActionContext", **kwargs: Any
|
|
180
|
+
) -> dict[str, Any] | None:
|
|
181
|
+
"""ActionHandler wrapper for mark_loop_complete."""
|
|
182
|
+
return mark_loop_complete(context.state)
|
|
@@ -161,3 +161,58 @@ def clear_stop_signal(
|
|
|
161
161
|
|
|
162
162
|
cleared = stop_registry.clear(session_id)
|
|
163
163
|
return {"success": True, "cleared": cleared}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- ActionHandler factory functions ---
|
|
167
|
+
# These create ActionHandler-compatible wrappers that close over the stop_registry.
|
|
168
|
+
# The ActionExecutor calls these factories in _register_defaults() to create handlers
|
|
169
|
+
# that have access to the executor's stop_registry instance.
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def make_handle_check_stop_signal(
|
|
173
|
+
stop_registry: "StopRegistry | None",
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Factory that creates a check_stop_signal handler with access to stop_registry."""
|
|
176
|
+
|
|
177
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
178
|
+
"""ActionHandler for check_stop_signal."""
|
|
179
|
+
return check_stop_signal(
|
|
180
|
+
stop_registry=stop_registry,
|
|
181
|
+
session_id=context.session_id,
|
|
182
|
+
state=context.state,
|
|
183
|
+
acknowledge=kwargs.get("acknowledge", False),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return handler
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def make_handle_request_stop(
|
|
190
|
+
stop_registry: "StopRegistry | None",
|
|
191
|
+
) -> Any:
|
|
192
|
+
"""Factory that creates a request_stop handler with access to stop_registry."""
|
|
193
|
+
|
|
194
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
195
|
+
"""ActionHandler for request_stop."""
|
|
196
|
+
return request_stop(
|
|
197
|
+
stop_registry=stop_registry,
|
|
198
|
+
session_id=kwargs.get("session_id", context.session_id),
|
|
199
|
+
source=kwargs.get("source", "workflow"),
|
|
200
|
+
reason=kwargs.get("reason"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return handler
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def make_handle_clear_stop_signal(
|
|
207
|
+
stop_registry: "StopRegistry | None",
|
|
208
|
+
) -> Any:
|
|
209
|
+
"""Factory that creates a clear_stop_signal handler with access to stop_registry."""
|
|
210
|
+
|
|
211
|
+
async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
|
|
212
|
+
"""ActionHandler for clear_stop_signal."""
|
|
213
|
+
return clear_stop_signal(
|
|
214
|
+
stop_registry=stop_registry,
|
|
215
|
+
session_id=kwargs.get("session_id", context.session_id),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return handler
|
|
@@ -9,16 +9,22 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any, Literal
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
13
|
|
|
14
14
|
from gobby.workflows.git_utils import get_file_changes, get_git_status
|
|
15
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gobby.workflows.actions import ActionContext
|
|
18
|
+
|
|
16
19
|
logger = logging.getLogger(__name__)
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
|
|
20
23
|
"""Format transcript turns for LLM analysis.
|
|
21
24
|
|
|
25
|
+
Handles both Claude Code format (nested message.role/content) and
|
|
26
|
+
Gemini CLI format (flat type/role/content).
|
|
27
|
+
|
|
22
28
|
Args:
|
|
23
29
|
turns: List of transcript turn dicts
|
|
24
30
|
|
|
@@ -27,51 +33,108 @@ def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
|
|
|
27
33
|
"""
|
|
28
34
|
formatted: list[str] = []
|
|
29
35
|
for i, turn in enumerate(turns):
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
elif block.get("type") == "thinking":
|
|
42
|
-
text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
|
|
43
|
-
elif block.get("type") == "tool_use":
|
|
44
|
-
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
|
45
|
-
elif block.get("type") == "tool_result":
|
|
46
|
-
result_content = block.get("content", "")
|
|
47
|
-
# Extract text from list of content blocks if needed
|
|
48
|
-
if isinstance(result_content, list):
|
|
49
|
-
extracted = []
|
|
50
|
-
for item in result_content:
|
|
51
|
-
if isinstance(item, dict):
|
|
52
|
-
extracted.append(
|
|
53
|
-
item.get("text", "") or item.get("content", "")
|
|
54
|
-
)
|
|
55
|
-
else:
|
|
56
|
-
extracted.append(str(item))
|
|
57
|
-
result_content = " ".join(extracted)
|
|
58
|
-
content_str = str(result_content)
|
|
59
|
-
preview = content_str[:100]
|
|
60
|
-
suffix = "..." if len(content_str) > 100 else ""
|
|
61
|
-
text_parts.append(f"[Result: {preview}{suffix}]")
|
|
62
|
-
content = " ".join(text_parts)
|
|
36
|
+
# Detect format: Gemini CLI uses "type" field, Claude uses nested "message"
|
|
37
|
+
event_type = turn.get("type")
|
|
38
|
+
|
|
39
|
+
if event_type:
|
|
40
|
+
# Gemini CLI format: flat structure with type field
|
|
41
|
+
role, content = _format_gemini_turn(turn, event_type)
|
|
42
|
+
if role is None:
|
|
43
|
+
continue # Skip non-displayable events
|
|
44
|
+
else:
|
|
45
|
+
# Claude Code format: nested message structure
|
|
46
|
+
role, content = _format_claude_turn(turn)
|
|
63
47
|
|
|
64
48
|
formatted.append(f"[Turn {i + 1} - {role}]: {content}")
|
|
65
49
|
|
|
66
50
|
return "\n\n".join(formatted)
|
|
67
51
|
|
|
68
52
|
|
|
53
|
+
def _format_gemini_turn(turn: dict[str, Any], event_type: str) -> tuple[str | None, str]:
|
|
54
|
+
"""Format a Gemini CLI turn.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (role, formatted_content) or (None, "") if should skip
|
|
58
|
+
"""
|
|
59
|
+
if event_type == "message":
|
|
60
|
+
role = turn.get("role", "unknown")
|
|
61
|
+
if role == "model":
|
|
62
|
+
role = "assistant"
|
|
63
|
+
content = turn.get("content", "")
|
|
64
|
+
if isinstance(content, list):
|
|
65
|
+
content = " ".join(str(part) for part in content)
|
|
66
|
+
return role, str(content)
|
|
67
|
+
|
|
68
|
+
elif event_type == "tool_use":
|
|
69
|
+
tool_name = turn.get("tool_name") or turn.get("function_name", "unknown")
|
|
70
|
+
params = turn.get("parameters") or turn.get("args", {})
|
|
71
|
+
param_preview = str(params)[:100] if params else ""
|
|
72
|
+
return "assistant", f"[Tool: {tool_name}] {param_preview}"
|
|
73
|
+
|
|
74
|
+
elif event_type == "tool_result":
|
|
75
|
+
tool_name = turn.get("tool_name", "")
|
|
76
|
+
output = turn.get("output") or turn.get("result", "")
|
|
77
|
+
output_str = str(output)
|
|
78
|
+
preview = output_str[:100]
|
|
79
|
+
suffix = "..." if len(output_str) > 100 else ""
|
|
80
|
+
return "tool", f"[Result{' from ' + tool_name if tool_name else ''}]: {preview}{suffix}"
|
|
81
|
+
|
|
82
|
+
elif event_type in ("init", "result"):
|
|
83
|
+
# Skip initialization and final result events
|
|
84
|
+
return None, ""
|
|
85
|
+
|
|
86
|
+
else:
|
|
87
|
+
# Unknown type, try to extract something
|
|
88
|
+
content = turn.get("content", turn.get("message", ""))
|
|
89
|
+
return "unknown", str(content)[:200]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_claude_turn(turn: dict[str, Any]) -> tuple[str, str]:
|
|
93
|
+
"""Format a Claude Code turn with nested message structure."""
|
|
94
|
+
message = turn.get("message", {})
|
|
95
|
+
role = message.get("role", "unknown")
|
|
96
|
+
content = message.get("content", "")
|
|
97
|
+
|
|
98
|
+
# Assistant messages have content as array of blocks
|
|
99
|
+
if isinstance(content, list):
|
|
100
|
+
text_parts: list[str] = []
|
|
101
|
+
for block in content:
|
|
102
|
+
if isinstance(block, dict):
|
|
103
|
+
if block.get("type") == "text":
|
|
104
|
+
text_parts.append(block.get("text", ""))
|
|
105
|
+
elif block.get("type") == "thinking":
|
|
106
|
+
text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
|
|
107
|
+
elif block.get("type") == "tool_use":
|
|
108
|
+
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
|
109
|
+
elif block.get("type") == "tool_result":
|
|
110
|
+
result_content = block.get("content", "")
|
|
111
|
+
# Extract text from list of content blocks if needed
|
|
112
|
+
if isinstance(result_content, list):
|
|
113
|
+
extracted = []
|
|
114
|
+
for item in result_content:
|
|
115
|
+
if isinstance(item, dict):
|
|
116
|
+
extracted.append(item.get("text", "") or item.get("content", ""))
|
|
117
|
+
else:
|
|
118
|
+
extracted.append(str(item))
|
|
119
|
+
result_content = " ".join(extracted)
|
|
120
|
+
content_str = str(result_content)
|
|
121
|
+
preview = content_str[:100]
|
|
122
|
+
suffix = "..." if len(content_str) > 100 else ""
|
|
123
|
+
text_parts.append(f"[Result: {preview}{suffix}]")
|
|
124
|
+
content = " ".join(text_parts)
|
|
125
|
+
|
|
126
|
+
return role, str(content)
|
|
127
|
+
|
|
128
|
+
|
|
69
129
|
def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
|
|
70
130
|
"""Extract the last TodoWrite tool call's todos list from transcript.
|
|
71
131
|
|
|
72
132
|
Scans turns in reverse to find the most recent TodoWrite tool call
|
|
73
133
|
and formats it as a markdown checklist.
|
|
74
134
|
|
|
135
|
+
Handles both Claude Code format (nested message.content) and
|
|
136
|
+
Gemini CLI format (flat type/tool_name/parameters).
|
|
137
|
+
|
|
75
138
|
Args:
|
|
76
139
|
turns: List of transcript turns
|
|
77
140
|
|
|
@@ -79,6 +142,16 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
|
|
|
79
142
|
Formatted markdown string with todo list, or empty string if not found
|
|
80
143
|
"""
|
|
81
144
|
for turn in reversed(turns):
|
|
145
|
+
# Check Gemini CLI format: flat structure with type="tool_use"
|
|
146
|
+
event_type = turn.get("type")
|
|
147
|
+
if event_type == "tool_use":
|
|
148
|
+
tool_name = turn.get("tool_name") or turn.get("function_name", "")
|
|
149
|
+
if tool_name == "TodoWrite":
|
|
150
|
+
tool_input = turn.get("parameters") or turn.get("args") or turn.get("input", {})
|
|
151
|
+
todos = tool_input.get("todos", [])
|
|
152
|
+
return _format_todos(todos)
|
|
153
|
+
|
|
154
|
+
# Check Claude Code format: nested message.content
|
|
82
155
|
message = turn.get("message", {})
|
|
83
156
|
content = message.get("content", [])
|
|
84
157
|
|
|
@@ -88,29 +161,32 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
|
|
|
88
161
|
if block.get("name") == "TodoWrite":
|
|
89
162
|
tool_input = block.get("input", {})
|
|
90
163
|
todos = tool_input.get("todos", [])
|
|
164
|
+
return _format_todos(todos)
|
|
91
165
|
|
|
92
|
-
|
|
93
|
-
return ""
|
|
166
|
+
return ""
|
|
94
167
|
|
|
95
|
-
# Format as markdown checklist
|
|
96
|
-
lines: list[str] = []
|
|
97
|
-
for todo in todos:
|
|
98
|
-
content_text = todo.get("content", "")
|
|
99
|
-
status = todo.get("status", "pending")
|
|
100
168
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
checkbox = "[>]"
|
|
106
|
-
else:
|
|
107
|
-
checkbox = "[ ]"
|
|
169
|
+
def _format_todos(todos: list[dict[str, Any]]) -> str:
|
|
170
|
+
"""Format todos list as markdown checklist."""
|
|
171
|
+
if not todos:
|
|
172
|
+
return ""
|
|
108
173
|
|
|
109
|
-
|
|
174
|
+
lines: list[str] = []
|
|
175
|
+
for todo in todos:
|
|
176
|
+
content_text = todo.get("content", "")
|
|
177
|
+
status = todo.get("status", "pending")
|
|
110
178
|
|
|
111
|
-
|
|
179
|
+
# Map status to checkbox style
|
|
180
|
+
if status == "completed":
|
|
181
|
+
checkbox = "[x]"
|
|
182
|
+
elif status == "in_progress":
|
|
183
|
+
checkbox = "[>]"
|
|
184
|
+
else:
|
|
185
|
+
checkbox = "[ ]"
|
|
112
186
|
|
|
113
|
-
|
|
187
|
+
lines.append(f"- {checkbox} {content_text}")
|
|
188
|
+
|
|
189
|
+
return "\n".join(lines)
|
|
114
190
|
|
|
115
191
|
|
|
116
192
|
async def synthesize_title(
|
|
@@ -359,3 +435,93 @@ async def generate_handoff(
|
|
|
359
435
|
return {"error": "Failed to generate summary"}
|
|
360
436
|
|
|
361
437
|
return {"handoff_created": True, "summary_length": summary_result.get("summary_length", 0)}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# --- ActionHandler-compatible wrappers ---
|
|
441
|
+
# These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def handle_synthesize_title(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
445
|
+
"""ActionHandler wrapper for synthesize_title."""
|
|
446
|
+
# Extract prompt from event data (UserPromptSubmit hook)
|
|
447
|
+
prompt = None
|
|
448
|
+
if context.event_data:
|
|
449
|
+
prompt = context.event_data.get("prompt")
|
|
450
|
+
|
|
451
|
+
return await synthesize_title(
|
|
452
|
+
session_manager=context.session_manager,
|
|
453
|
+
session_id=context.session_id,
|
|
454
|
+
llm_service=context.llm_service,
|
|
455
|
+
transcript_processor=context.transcript_processor,
|
|
456
|
+
template_engine=context.template_engine,
|
|
457
|
+
template=kwargs.get("template"),
|
|
458
|
+
prompt=prompt,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def handle_generate_summary(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
463
|
+
"""ActionHandler wrapper for generate_summary."""
|
|
464
|
+
return await generate_summary(
|
|
465
|
+
session_manager=context.session_manager,
|
|
466
|
+
session_id=context.session_id,
|
|
467
|
+
llm_service=context.llm_service,
|
|
468
|
+
transcript_processor=context.transcript_processor,
|
|
469
|
+
template=kwargs.get("template"),
|
|
470
|
+
mode=kwargs.get("mode", "clear"),
|
|
471
|
+
previous_summary=kwargs.get("previous_summary"),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def handle_generate_handoff(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
|
|
476
|
+
"""ActionHandler wrapper for generate_handoff.
|
|
477
|
+
|
|
478
|
+
Handles mode detection from event_data and previous summary fetching for compact mode.
|
|
479
|
+
Also supports loading templates from prompts collection via 'prompt' parameter.
|
|
480
|
+
"""
|
|
481
|
+
# Detect mode from kwargs or event data
|
|
482
|
+
mode = kwargs.get("mode", "clear")
|
|
483
|
+
|
|
484
|
+
# Check if this is a compact event based on event_data
|
|
485
|
+
COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
|
|
486
|
+
if context.event_data:
|
|
487
|
+
raw_event_type = context.event_data.get("event_type") or ""
|
|
488
|
+
normalized_event_type = str(raw_event_type).strip().lower()
|
|
489
|
+
if normalized_event_type in COMPACT_EVENT_TYPES:
|
|
490
|
+
mode = "compact"
|
|
491
|
+
|
|
492
|
+
# For compact mode, fetch previous summary for cumulative compression
|
|
493
|
+
previous_summary = None
|
|
494
|
+
if mode == "compact":
|
|
495
|
+
current_session = context.session_manager.get(context.session_id)
|
|
496
|
+
if current_session:
|
|
497
|
+
previous_summary = getattr(current_session, "summary_markdown", None)
|
|
498
|
+
if previous_summary:
|
|
499
|
+
logger.debug(
|
|
500
|
+
f"Compact mode: using previous summary ({len(previous_summary)} chars) "
|
|
501
|
+
f"for cumulative compression"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Load template from prompts collection if 'prompt' parameter provided
|
|
505
|
+
template = kwargs.get("template")
|
|
506
|
+
prompt_path = kwargs.get("prompt")
|
|
507
|
+
if prompt_path and not template:
|
|
508
|
+
try:
|
|
509
|
+
from gobby.prompts.loader import PromptLoader
|
|
510
|
+
|
|
511
|
+
loader = PromptLoader()
|
|
512
|
+
prompt_template = loader.load(prompt_path)
|
|
513
|
+
template = prompt_template.content
|
|
514
|
+
logger.debug(f"Loaded prompt template from: {prompt_path}")
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
|
|
517
|
+
# Fall back to inline template or default
|
|
518
|
+
|
|
519
|
+
return await generate_handoff(
|
|
520
|
+
session_manager=context.session_manager,
|
|
521
|
+
session_id=context.session_id,
|
|
522
|
+
llm_service=context.llm_service,
|
|
523
|
+
transcript_processor=context.transcript_processor,
|
|
524
|
+
template=template,
|
|
525
|
+
previous_summary=previous_summary,
|
|
526
|
+
mode=mode,
|
|
527
|
+
)
|