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
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Handoff helper functions and tools for session management.
|
|
2
|
+
|
|
3
|
+
This module contains:
|
|
4
|
+
- Helper functions for formatting handoff context
|
|
5
|
+
- MCP tools for creating and retrieving handoffs
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from gobby.mcp_proxy.tools.internal import InternalToolRegistry
|
|
14
|
+
from gobby.sessions.analyzer import HandoffContext
|
|
15
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_handoff_markdown(ctx: HandoffContext, notes: str | None = None) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Format HandoffContext as markdown for session handoff.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
ctx: HandoffContext with extracted session data
|
|
24
|
+
notes: Optional additional notes to include
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Formatted markdown string
|
|
28
|
+
"""
|
|
29
|
+
sections: list[str] = ["## Continuation Context", ""]
|
|
30
|
+
|
|
31
|
+
# Active task section
|
|
32
|
+
if ctx.active_gobby_task:
|
|
33
|
+
task = ctx.active_gobby_task
|
|
34
|
+
sections.append("### Active Task")
|
|
35
|
+
sections.append(f"**{task.get('title', 'Untitled')}** ({task.get('id', 'unknown')})")
|
|
36
|
+
sections.append(f"Status: {task.get('status', 'unknown')}")
|
|
37
|
+
sections.append("")
|
|
38
|
+
|
|
39
|
+
# Todo state section
|
|
40
|
+
if ctx.todo_state:
|
|
41
|
+
sections.append("### In-Progress Work")
|
|
42
|
+
for todo in ctx.todo_state:
|
|
43
|
+
status = todo.get("status", "pending")
|
|
44
|
+
marker = "x" if status == "completed" else ">" if status == "in_progress" else " "
|
|
45
|
+
sections.append(f"- [{marker}] {todo.get('content', '')}")
|
|
46
|
+
sections.append("")
|
|
47
|
+
|
|
48
|
+
# Git commits section
|
|
49
|
+
if ctx.git_commits:
|
|
50
|
+
sections.append("### Commits This Session")
|
|
51
|
+
for commit in ctx.git_commits:
|
|
52
|
+
sections.append(f"- `{commit.get('hash', '')[:7]}` {commit.get('message', '')}")
|
|
53
|
+
sections.append("")
|
|
54
|
+
|
|
55
|
+
# Git status section
|
|
56
|
+
if ctx.git_status:
|
|
57
|
+
sections.append("### Uncommitted Changes")
|
|
58
|
+
sections.append("```")
|
|
59
|
+
sections.append(ctx.git_status)
|
|
60
|
+
sections.append("```")
|
|
61
|
+
sections.append("")
|
|
62
|
+
|
|
63
|
+
# Files modified section
|
|
64
|
+
if ctx.files_modified:
|
|
65
|
+
sections.append("### Files Being Modified")
|
|
66
|
+
for f in ctx.files_modified:
|
|
67
|
+
sections.append(f"- {f}")
|
|
68
|
+
sections.append("")
|
|
69
|
+
|
|
70
|
+
# Initial goal section
|
|
71
|
+
if ctx.initial_goal:
|
|
72
|
+
sections.append("### Original Goal")
|
|
73
|
+
sections.append(ctx.initial_goal)
|
|
74
|
+
sections.append("")
|
|
75
|
+
|
|
76
|
+
# Recent activity section
|
|
77
|
+
if ctx.recent_activity:
|
|
78
|
+
sections.append("### Recent Activity")
|
|
79
|
+
for activity in ctx.recent_activity[-5:]:
|
|
80
|
+
sections.append(f"- {activity}")
|
|
81
|
+
sections.append("")
|
|
82
|
+
|
|
83
|
+
# Notes section (if provided)
|
|
84
|
+
if notes:
|
|
85
|
+
sections.append("### Notes")
|
|
86
|
+
sections.append(notes)
|
|
87
|
+
sections.append("")
|
|
88
|
+
|
|
89
|
+
return "\n".join(sections)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
|
|
93
|
+
"""Format transcript turns for LLM analysis."""
|
|
94
|
+
formatted: list[str] = []
|
|
95
|
+
for i, turn in enumerate(turns):
|
|
96
|
+
message = turn.get("message", {})
|
|
97
|
+
role = message.get("role", "unknown")
|
|
98
|
+
content = message.get("content", "")
|
|
99
|
+
|
|
100
|
+
if isinstance(content, list):
|
|
101
|
+
text_parts: list[str] = []
|
|
102
|
+
for block in content:
|
|
103
|
+
if isinstance(block, dict):
|
|
104
|
+
if block.get("type") == "text":
|
|
105
|
+
text_parts.append(str(block.get("text", "")))
|
|
106
|
+
elif block.get("type") == "tool_use":
|
|
107
|
+
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
|
108
|
+
content = " ".join(text_parts)
|
|
109
|
+
|
|
110
|
+
formatted.append(f"[Turn {i + 1} - {role}]: {content}")
|
|
111
|
+
|
|
112
|
+
return "\n\n".join(formatted)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def register_handoff_tools(
|
|
116
|
+
registry: InternalToolRegistry,
|
|
117
|
+
session_manager: LocalSessionManager,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Register handoff tools with a registry.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
registry: The InternalToolRegistry to register tools with
|
|
124
|
+
session_manager: LocalSessionManager instance for session operations
|
|
125
|
+
"""
|
|
126
|
+
from gobby.utils.project_context import get_project_context
|
|
127
|
+
|
|
128
|
+
def _resolve_session_id(ref: str) -> str:
|
|
129
|
+
"""Resolve session reference (#N, N, UUID, or prefix) to UUID."""
|
|
130
|
+
project_ctx = get_project_context()
|
|
131
|
+
project_id = project_ctx.get("id") if project_ctx else None
|
|
132
|
+
|
|
133
|
+
return session_manager.resolve_session_reference(ref, project_id)
|
|
134
|
+
|
|
135
|
+
@registry.tool(
|
|
136
|
+
name="get_handoff_context",
|
|
137
|
+
description="Get the handoff context (compact_markdown) for a session. Accepts #N, N, UUID, or prefix.",
|
|
138
|
+
)
|
|
139
|
+
def get_handoff_context(session_id: str) -> dict[str, Any]:
|
|
140
|
+
"""
|
|
141
|
+
Retrieve stored handoff context.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Session ID, compact_markdown, and whether context exists
|
|
148
|
+
"""
|
|
149
|
+
if not session_manager:
|
|
150
|
+
raise RuntimeError("Session manager not available")
|
|
151
|
+
|
|
152
|
+
# Get project_id for project-scoped resolution
|
|
153
|
+
project_ctx = get_project_context()
|
|
154
|
+
project_id = project_ctx.get("id") if project_ctx else None
|
|
155
|
+
|
|
156
|
+
# Resolve #N format, UUID, or prefix
|
|
157
|
+
try:
|
|
158
|
+
resolved_id = session_manager.resolve_session_reference(session_id, project_id)
|
|
159
|
+
session = session_manager.get(resolved_id)
|
|
160
|
+
except ValueError:
|
|
161
|
+
session = None
|
|
162
|
+
if not session:
|
|
163
|
+
return {"error": f"Session {session_id} not found", "found": False}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"session_id": session.id,
|
|
167
|
+
"ref": f"#{session.seq_num}" if session.seq_num else session.id[:8],
|
|
168
|
+
"compact_markdown": session.compact_markdown,
|
|
169
|
+
"has_context": bool(session.compact_markdown),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@registry.tool(
|
|
173
|
+
name="create_handoff",
|
|
174
|
+
description="""Create handoff context by extracting structured data from the session transcript. Accepts #N, N, UUID, or prefix for session_id.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
session_id: (REQUIRED) Your session ID. Accepts #N, N, UUID, or prefix. Get it from:
|
|
178
|
+
1. Your injected context (look for 'Session Ref: #N' or 'session_id: xxx')
|
|
179
|
+
2. Or call get_current_session(external_id, source) first""",
|
|
180
|
+
)
|
|
181
|
+
async def create_handoff(
|
|
182
|
+
session_id: str,
|
|
183
|
+
notes: str | None = None,
|
|
184
|
+
compact: bool = False,
|
|
185
|
+
full: bool = False,
|
|
186
|
+
write_file: bool = True,
|
|
187
|
+
output_path: str = ".gobby/session_summaries/",
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""
|
|
190
|
+
Create handoff context for a session.
|
|
191
|
+
|
|
192
|
+
Generates compact (TranscriptAnalyzer) and/or full (LLM) summaries.
|
|
193
|
+
Always saves to database. Optionally writes to file.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (REQUIRED)
|
|
197
|
+
notes: Additional notes to include in handoff
|
|
198
|
+
compact: Generate compact summary only (default: False, neither = both)
|
|
199
|
+
full: Generate full LLM summary only (default: False, neither = both)
|
|
200
|
+
write_file: Also write to file (default: True). DB is always written.
|
|
201
|
+
output_path: Directory for file output (default: .gobby/session_summaries/ in project)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Success status, markdown lengths, and extracted context summary
|
|
205
|
+
"""
|
|
206
|
+
import json
|
|
207
|
+
import subprocess # nosec B404 - subprocess needed for git commands
|
|
208
|
+
import time
|
|
209
|
+
from pathlib import Path
|
|
210
|
+
|
|
211
|
+
from gobby.sessions.analyzer import TranscriptAnalyzer
|
|
212
|
+
|
|
213
|
+
if session_manager is None:
|
|
214
|
+
return {"success": False, "error": "Session manager not available"}
|
|
215
|
+
|
|
216
|
+
# Resolve session reference (#N, N, UUID, or prefix)
|
|
217
|
+
try:
|
|
218
|
+
resolved_id = _resolve_session_id(session_id)
|
|
219
|
+
session = session_manager.get(resolved_id)
|
|
220
|
+
except ValueError as e:
|
|
221
|
+
return {"success": False, "error": str(e), "session_id": session_id}
|
|
222
|
+
|
|
223
|
+
if not session:
|
|
224
|
+
return {"success": False, "error": "No session found", "session_id": session_id}
|
|
225
|
+
|
|
226
|
+
# Get transcript path
|
|
227
|
+
transcript_path = session.jsonl_path
|
|
228
|
+
if not transcript_path:
|
|
229
|
+
return {
|
|
230
|
+
"success": False,
|
|
231
|
+
"error": "No transcript path for session",
|
|
232
|
+
"session_id": session.id,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
path = Path(transcript_path)
|
|
236
|
+
if not path.exists():
|
|
237
|
+
return {
|
|
238
|
+
"success": False,
|
|
239
|
+
"error": "Transcript file not found",
|
|
240
|
+
"path": transcript_path,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Read and parse transcript
|
|
244
|
+
turns = []
|
|
245
|
+
with open(path, encoding="utf-8") as f:
|
|
246
|
+
for line in f:
|
|
247
|
+
if line.strip():
|
|
248
|
+
turns.append(json.loads(line))
|
|
249
|
+
|
|
250
|
+
# Analyze transcript
|
|
251
|
+
analyzer = TranscriptAnalyzer()
|
|
252
|
+
handoff_ctx = analyzer.extract_handoff_context(turns)
|
|
253
|
+
|
|
254
|
+
# Enrich with real-time git status
|
|
255
|
+
if not handoff_ctx.git_status:
|
|
256
|
+
try:
|
|
257
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
258
|
+
["git", "status", "--short"],
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
timeout=5,
|
|
262
|
+
cwd=path.parent,
|
|
263
|
+
)
|
|
264
|
+
handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
|
|
265
|
+
except Exception:
|
|
266
|
+
pass # nosec B110 - git status is optional, ignore failures
|
|
267
|
+
|
|
268
|
+
# Get recent git commits
|
|
269
|
+
try:
|
|
270
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
271
|
+
["git", "log", "--oneline", "-10", "--format=%H|%s"],
|
|
272
|
+
capture_output=True,
|
|
273
|
+
text=True,
|
|
274
|
+
timeout=5,
|
|
275
|
+
cwd=path.parent,
|
|
276
|
+
)
|
|
277
|
+
if result.returncode == 0:
|
|
278
|
+
commits = []
|
|
279
|
+
for line in result.stdout.strip().split("\n"):
|
|
280
|
+
if "|" in line:
|
|
281
|
+
hash_val, message = line.split("|", 1)
|
|
282
|
+
commits.append({"hash": hash_val, "message": message})
|
|
283
|
+
if commits:
|
|
284
|
+
handoff_ctx.git_commits = commits
|
|
285
|
+
except Exception:
|
|
286
|
+
pass # nosec B110 - git log is optional, ignore failures
|
|
287
|
+
|
|
288
|
+
# Determine what to generate (neither flag = both)
|
|
289
|
+
generate_compact = compact or not full
|
|
290
|
+
generate_full = full or not compact
|
|
291
|
+
|
|
292
|
+
# Generate content
|
|
293
|
+
compact_markdown = None
|
|
294
|
+
full_markdown = None
|
|
295
|
+
full_error = None
|
|
296
|
+
|
|
297
|
+
if generate_compact:
|
|
298
|
+
compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
|
|
299
|
+
|
|
300
|
+
if generate_full:
|
|
301
|
+
try:
|
|
302
|
+
from gobby.config.app import load_config
|
|
303
|
+
from gobby.llm.claude import ClaudeLLMProvider
|
|
304
|
+
from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
|
|
305
|
+
|
|
306
|
+
config = load_config()
|
|
307
|
+
provider = ClaudeLLMProvider(config)
|
|
308
|
+
transcript_parser = ClaudeTranscriptParser()
|
|
309
|
+
|
|
310
|
+
# Get prompt template from config
|
|
311
|
+
prompt_template = None
|
|
312
|
+
if hasattr(config, "session_summary") and config.session_summary:
|
|
313
|
+
prompt_template = getattr(config.session_summary, "prompt", None)
|
|
314
|
+
|
|
315
|
+
if not prompt_template:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"No prompt template configured. "
|
|
318
|
+
"Set 'session_summary.prompt' in ~/.gobby/config.yaml"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Prepare context for LLM
|
|
322
|
+
last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
|
|
323
|
+
last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
|
|
324
|
+
|
|
325
|
+
context = {
|
|
326
|
+
"transcript_summary": _format_turns_for_llm(last_turns),
|
|
327
|
+
"last_messages": last_messages,
|
|
328
|
+
"git_status": handoff_ctx.git_status or "",
|
|
329
|
+
"file_changes": "",
|
|
330
|
+
"external_id": session.id[:12],
|
|
331
|
+
"session_id": session.id,
|
|
332
|
+
"session_source": session.source,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
full_markdown = await provider.generate_summary(
|
|
336
|
+
context, prompt_template=prompt_template
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
full_error = str(e)
|
|
341
|
+
if full and not compact:
|
|
342
|
+
return {
|
|
343
|
+
"success": False,
|
|
344
|
+
"error": f"Failed to generate full summary: {e}",
|
|
345
|
+
"session_id": session.id,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Always save to database
|
|
349
|
+
if compact_markdown:
|
|
350
|
+
session_manager.update_compact_markdown(session.id, compact_markdown)
|
|
351
|
+
if full_markdown:
|
|
352
|
+
session_manager.update_summary(session.id, summary_markdown=full_markdown)
|
|
353
|
+
|
|
354
|
+
# Save to file if requested
|
|
355
|
+
files_written = []
|
|
356
|
+
if write_file:
|
|
357
|
+
try:
|
|
358
|
+
summary_dir = Path(output_path)
|
|
359
|
+
if not summary_dir.is_absolute():
|
|
360
|
+
summary_dir = Path.cwd() / summary_dir
|
|
361
|
+
summary_dir.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
timestamp = int(time.time())
|
|
363
|
+
|
|
364
|
+
if full_markdown:
|
|
365
|
+
full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
|
|
366
|
+
full_file.write_text(full_markdown, encoding="utf-8")
|
|
367
|
+
files_written.append(str(full_file))
|
|
368
|
+
|
|
369
|
+
if compact_markdown:
|
|
370
|
+
compact_file = summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
|
|
371
|
+
compact_file.write_text(compact_markdown, encoding="utf-8")
|
|
372
|
+
files_written.append(str(compact_file))
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return {
|
|
376
|
+
"success": False,
|
|
377
|
+
"error": f"Failed to write file: {e}",
|
|
378
|
+
"session_id": session.id,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
"success": True,
|
|
383
|
+
"session_id": session.id,
|
|
384
|
+
"compact_length": len(compact_markdown) if compact_markdown else 0,
|
|
385
|
+
"full_length": len(full_markdown) if full_markdown else 0,
|
|
386
|
+
"full_error": full_error,
|
|
387
|
+
"files_written": files_written,
|
|
388
|
+
"context_summary": {
|
|
389
|
+
"has_active_task": bool(handoff_ctx.active_gobby_task),
|
|
390
|
+
"todo_count": len(handoff_ctx.todo_state),
|
|
391
|
+
"files_modified_count": len(handoff_ctx.files_modified),
|
|
392
|
+
"git_commits_count": len(handoff_ctx.git_commits),
|
|
393
|
+
"has_initial_goal": bool(handoff_ctx.initial_goal),
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@registry.tool(
|
|
398
|
+
name="pickup",
|
|
399
|
+
description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks. Accepts #N, N, UUID, or prefix for session_id.",
|
|
400
|
+
)
|
|
401
|
+
def pickup(
|
|
402
|
+
session_id: str | None = None,
|
|
403
|
+
project_id: str | None = None,
|
|
404
|
+
source: str | None = None,
|
|
405
|
+
link_child_session_id: str | None = None,
|
|
406
|
+
) -> dict[str, Any]:
|
|
407
|
+
"""
|
|
408
|
+
Restore context from a previous session's handoff.
|
|
409
|
+
|
|
410
|
+
This tool is designed for CLIs and IDEs that don't have a hooks system.
|
|
411
|
+
It finds the most recent handoff-ready session and returns its context
|
|
412
|
+
for injection into a new session.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (optional)
|
|
416
|
+
project_id: Project ID to find parent session in (optional)
|
|
417
|
+
source: Filter by CLI source - claude_code, gemini, codex (optional)
|
|
418
|
+
link_child_session_id: Session to link as child - supports #N, N, UUID, or prefix (optional)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Handoff context markdown and session metadata
|
|
422
|
+
"""
|
|
423
|
+
from gobby.utils.machine_id import get_machine_id
|
|
424
|
+
|
|
425
|
+
if session_manager is None:
|
|
426
|
+
return {"error": "Session manager not available"}
|
|
427
|
+
|
|
428
|
+
parent_session = None
|
|
429
|
+
|
|
430
|
+
# Option 1: Direct session_id lookup with resolution
|
|
431
|
+
if session_id:
|
|
432
|
+
try:
|
|
433
|
+
resolved_id = _resolve_session_id(session_id)
|
|
434
|
+
parent_session = session_manager.get(resolved_id)
|
|
435
|
+
except ValueError as e:
|
|
436
|
+
return {"error": str(e)}
|
|
437
|
+
|
|
438
|
+
# Option 2: Find parent by project_id and source
|
|
439
|
+
if not parent_session and project_id:
|
|
440
|
+
machine_id = get_machine_id()
|
|
441
|
+
if machine_id:
|
|
442
|
+
parent_session = session_manager.find_parent(
|
|
443
|
+
machine_id=machine_id,
|
|
444
|
+
project_id=project_id,
|
|
445
|
+
source=source,
|
|
446
|
+
status="handoff_ready",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Option 3: Find most recent handoff_ready session
|
|
450
|
+
if not parent_session:
|
|
451
|
+
sessions = session_manager.list(status="handoff_ready", limit=1)
|
|
452
|
+
parent_session = sessions[0] if sessions else None
|
|
453
|
+
|
|
454
|
+
if not parent_session:
|
|
455
|
+
return {
|
|
456
|
+
"found": False,
|
|
457
|
+
"message": "No handoff-ready session found",
|
|
458
|
+
"filters": {
|
|
459
|
+
"session_id": session_id,
|
|
460
|
+
"project_id": project_id,
|
|
461
|
+
"source": source,
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Get handoff context (prefer compact_markdown, fall back to summary_markdown)
|
|
466
|
+
context = parent_session.compact_markdown or parent_session.summary_markdown
|
|
467
|
+
|
|
468
|
+
if not context:
|
|
469
|
+
return {
|
|
470
|
+
"found": True,
|
|
471
|
+
"session_id": parent_session.id,
|
|
472
|
+
"has_context": False,
|
|
473
|
+
"message": "Session found but has no handoff context",
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# Optionally link child session (resolve if using #N format)
|
|
477
|
+
resolved_child_id = None
|
|
478
|
+
if link_child_session_id:
|
|
479
|
+
try:
|
|
480
|
+
resolved_child_id = _resolve_session_id(link_child_session_id)
|
|
481
|
+
session_manager.update_parent_session_id(resolved_child_id, parent_session.id)
|
|
482
|
+
except ValueError as e:
|
|
483
|
+
# Do not fallback to raw reference - propagate the error
|
|
484
|
+
return {
|
|
485
|
+
"found": True,
|
|
486
|
+
"session_id": parent_session.id,
|
|
487
|
+
"has_context": True,
|
|
488
|
+
"error": f"Failed to resolve child session '{link_child_session_id}': {e}",
|
|
489
|
+
"context": context,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"found": True,
|
|
494
|
+
"session_id": parent_session.id,
|
|
495
|
+
"has_context": True,
|
|
496
|
+
"context": context,
|
|
497
|
+
"context_type": (
|
|
498
|
+
"compact_markdown" if parent_session.compact_markdown else "summary_markdown"
|
|
499
|
+
),
|
|
500
|
+
"parent_title": parent_session.title,
|
|
501
|
+
"parent_status": parent_session.status,
|
|
502
|
+
"linked_child": resolved_child_id or link_child_session_id,
|
|
503
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Message retrieval and search tools for session management.
|
|
2
|
+
|
|
3
|
+
This module contains MCP tools for:
|
|
4
|
+
- Getting messages for a session (get_session_messages)
|
|
5
|
+
- Searching messages using FTS (search_messages)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from gobby.mcp_proxy.tools.internal import InternalToolRegistry
|
|
14
|
+
from gobby.storage.session_messages import LocalSessionMessageManager
|
|
15
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_message_tools(
|
|
19
|
+
registry: InternalToolRegistry,
|
|
20
|
+
message_manager: LocalSessionMessageManager,
|
|
21
|
+
session_manager: LocalSessionManager | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Register message retrieval and search tools with a registry.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
registry: The InternalToolRegistry to register tools with
|
|
28
|
+
message_manager: LocalSessionMessageManager instance for message operations
|
|
29
|
+
session_manager: LocalSessionManager for resolving session references
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def _resolve_session_id(session_id: str) -> str:
|
|
33
|
+
"""Resolve session reference (#N, N, UUID, or prefix) to UUID.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
str: Resolved UUID on success
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If session reference cannot be resolved (when session_manager exists)
|
|
40
|
+
"""
|
|
41
|
+
if not session_manager:
|
|
42
|
+
return session_id # Fall back to raw value if no manager (backward compat)
|
|
43
|
+
|
|
44
|
+
from gobby.utils.project_context import get_project_context
|
|
45
|
+
|
|
46
|
+
project_ctx = get_project_context()
|
|
47
|
+
project_id = project_ctx.get("id") if project_ctx else None
|
|
48
|
+
|
|
49
|
+
return session_manager.resolve_session_reference(session_id, project_id)
|
|
50
|
+
|
|
51
|
+
@registry.tool(
|
|
52
|
+
name="get_session_messages",
|
|
53
|
+
description="Get messages for a session. Accepts #N, N, UUID, or prefix for session_id.",
|
|
54
|
+
)
|
|
55
|
+
async def get_session_messages(
|
|
56
|
+
session_id: str,
|
|
57
|
+
limit: int = 50,
|
|
58
|
+
offset: int = 0,
|
|
59
|
+
full_content: bool = False,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""
|
|
62
|
+
Get messages for a session.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
|
|
66
|
+
limit: Max messages to return
|
|
67
|
+
offset: Offset for pagination
|
|
68
|
+
full_content: If True, returns full content. If False (default), truncates large content.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if not message_manager:
|
|
72
|
+
raise RuntimeError("Message manager not available")
|
|
73
|
+
|
|
74
|
+
resolved_id = _resolve_session_id(session_id)
|
|
75
|
+
messages = await message_manager.get_messages(
|
|
76
|
+
session_id=resolved_id,
|
|
77
|
+
limit=limit,
|
|
78
|
+
offset=offset,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Truncate content if not full_content
|
|
82
|
+
if not full_content:
|
|
83
|
+
for msg in messages:
|
|
84
|
+
if "content" in msg and msg["content"] and isinstance(msg["content"], str):
|
|
85
|
+
if len(msg["content"]) > 500:
|
|
86
|
+
msg["content"] = msg["content"][:500] + "... (truncated)"
|
|
87
|
+
|
|
88
|
+
if "tool_calls" in msg and msg["tool_calls"]:
|
|
89
|
+
for tc in msg["tool_calls"]:
|
|
90
|
+
if (
|
|
91
|
+
"input" in tc
|
|
92
|
+
and isinstance(tc["input"], str)
|
|
93
|
+
and len(tc["input"]) > 200
|
|
94
|
+
):
|
|
95
|
+
tc["input"] = tc["input"][:200] + "... (truncated)"
|
|
96
|
+
|
|
97
|
+
if "tool_result" in msg and msg["tool_result"]:
|
|
98
|
+
tr = msg["tool_result"]
|
|
99
|
+
if (
|
|
100
|
+
"content" in tr
|
|
101
|
+
and isinstance(tr["content"], str)
|
|
102
|
+
and len(tr["content"]) > 200
|
|
103
|
+
):
|
|
104
|
+
tr["content"] = tr["content"][:200] + "... (truncated)"
|
|
105
|
+
|
|
106
|
+
session_total = await message_manager.count_messages(resolved_id)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"success": True,
|
|
110
|
+
"messages": messages,
|
|
111
|
+
"total_count": session_total,
|
|
112
|
+
"returned_count": len(messages),
|
|
113
|
+
"limit": limit,
|
|
114
|
+
"offset": offset,
|
|
115
|
+
"truncated": not full_content,
|
|
116
|
+
}
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return {"success": False, "error": str(e)}
|
|
119
|
+
|
|
120
|
+
@registry.tool(
|
|
121
|
+
name="search_messages",
|
|
122
|
+
description="Search messages using Full Text Search (FTS). Accepts #N, N, UUID, or prefix for session_id.",
|
|
123
|
+
)
|
|
124
|
+
async def search_messages(
|
|
125
|
+
query: str,
|
|
126
|
+
session_id: str | None = None,
|
|
127
|
+
limit: int = 20,
|
|
128
|
+
full_content: bool = False,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""
|
|
131
|
+
Search messages.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
query: Search query
|
|
135
|
+
session_id: Optional session filter - supports #N, N (seq_num), UUID, or prefix
|
|
136
|
+
limit: Max results
|
|
137
|
+
full_content: If True, returns full content. If False (default), truncates large content.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
if not message_manager:
|
|
141
|
+
raise RuntimeError("Message manager not available")
|
|
142
|
+
|
|
143
|
+
resolved_session_id = None
|
|
144
|
+
if session_id:
|
|
145
|
+
resolved_session_id = _resolve_session_id(session_id)
|
|
146
|
+
results = await message_manager.search_messages(
|
|
147
|
+
query_text=query,
|
|
148
|
+
session_id=resolved_session_id,
|
|
149
|
+
limit=limit,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Truncate content if not full_content
|
|
153
|
+
if not full_content:
|
|
154
|
+
for msg in results:
|
|
155
|
+
if "content" in msg and msg["content"] and isinstance(msg["content"], str):
|
|
156
|
+
if len(msg["content"]) > 500:
|
|
157
|
+
msg["content"] = msg["content"][:500] + "... (truncated)"
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"success": True,
|
|
161
|
+
"results": results,
|
|
162
|
+
"count": len(results),
|
|
163
|
+
"truncated": not full_content,
|
|
164
|
+
}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return {"success": False, "error": str(e)}
|