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