gobby 0.2.5__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 +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/cli/projects.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project management CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gobby.storage.database import LocalDatabase
|
|
10
|
+
from gobby.storage.projects import LocalProjectManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_project_manager() -> LocalProjectManager:
|
|
14
|
+
"""Get initialized project manager."""
|
|
15
|
+
db = LocalDatabase()
|
|
16
|
+
return LocalProjectManager(db)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def projects() -> None:
|
|
21
|
+
"""Manage Gobby projects."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@projects.command("list")
|
|
26
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
27
|
+
def list_projects(json_format: bool) -> None:
|
|
28
|
+
"""List all known projects."""
|
|
29
|
+
manager = get_project_manager()
|
|
30
|
+
projects_list = manager.list()
|
|
31
|
+
|
|
32
|
+
if json_format:
|
|
33
|
+
click.echo(json.dumps([p.to_dict() for p in projects_list], indent=2, default=str))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if not projects_list:
|
|
37
|
+
click.echo("No projects found.")
|
|
38
|
+
click.echo("Use 'gobby init' in a project directory to register it.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
click.echo(f"Found {len(projects_list)} project(s):\n")
|
|
42
|
+
for project in projects_list:
|
|
43
|
+
path_info = f" {project.repo_path}" if project.repo_path else ""
|
|
44
|
+
click.echo(f" {project.name:<20} {project.id[:12]}{path_info}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@projects.command("show")
|
|
48
|
+
@click.argument("project_ref")
|
|
49
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
50
|
+
def show_project(project_ref: str, json_format: bool) -> None:
|
|
51
|
+
"""Show details for a project.
|
|
52
|
+
|
|
53
|
+
PROJECT_REF can be a project name or UUID.
|
|
54
|
+
"""
|
|
55
|
+
manager = get_project_manager()
|
|
56
|
+
|
|
57
|
+
# Try as UUID first, then as name
|
|
58
|
+
project = manager.get(project_ref)
|
|
59
|
+
if not project:
|
|
60
|
+
project = manager.get_by_name(project_ref)
|
|
61
|
+
|
|
62
|
+
if not project:
|
|
63
|
+
click.echo(f"Project not found: {project_ref}", err=True)
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
|
|
66
|
+
if json_format:
|
|
67
|
+
click.echo(json.dumps(project.to_dict(), indent=2, default=str))
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
click.echo(f"Project: {project.name}")
|
|
71
|
+
click.echo(f" ID: {project.id}")
|
|
72
|
+
if project.repo_path:
|
|
73
|
+
click.echo(f" Path: {project.repo_path}")
|
|
74
|
+
if project.github_url:
|
|
75
|
+
click.echo(f" GitHub: {project.github_url}")
|
|
76
|
+
if project.github_repo:
|
|
77
|
+
click.echo(f" Repo: {project.github_repo}")
|
|
78
|
+
click.echo(f" Created: {project.created_at}")
|
|
79
|
+
click.echo(f" Updated: {project.updated_at}")
|
gobby/cli/sessions.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from gobby.cli.utils import resolve_project_ref, resolve_session_id
|
|
12
|
+
from gobby.storage.database import LocalDatabase
|
|
13
|
+
from gobby.storage.session_messages import LocalSessionMessageManager
|
|
14
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_session_manager() -> LocalSessionManager:
|
|
18
|
+
"""Get initialized session manager."""
|
|
19
|
+
db = LocalDatabase()
|
|
20
|
+
return LocalSessionManager(db)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_message_manager() -> LocalSessionMessageManager:
|
|
24
|
+
"""Get initialized message manager."""
|
|
25
|
+
db = LocalDatabase()
|
|
26
|
+
return LocalSessionMessageManager(db)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
|
|
30
|
+
"""Format transcript turns for LLM analysis."""
|
|
31
|
+
formatted: list[str] = []
|
|
32
|
+
for i, turn in enumerate(turns):
|
|
33
|
+
message = turn.get("message", {})
|
|
34
|
+
role = message.get("role", "unknown")
|
|
35
|
+
content = message.get("content", "")
|
|
36
|
+
|
|
37
|
+
if isinstance(content, list):
|
|
38
|
+
text_parts: list[str] = []
|
|
39
|
+
for block in content:
|
|
40
|
+
if isinstance(block, dict):
|
|
41
|
+
if block.get("type") == "text":
|
|
42
|
+
text_parts.append(str(block.get("text", "")))
|
|
43
|
+
elif block.get("type") == "tool_use":
|
|
44
|
+
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
|
45
|
+
content = " ".join(text_parts)
|
|
46
|
+
|
|
47
|
+
formatted.append(f"[Turn {i + 1} - {role}]: {content}")
|
|
48
|
+
|
|
49
|
+
return "\n\n".join(formatted)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.group()
|
|
53
|
+
def sessions() -> None:
|
|
54
|
+
"""Manage Gobby sessions."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@sessions.command("list")
|
|
59
|
+
@click.option("--project", "-p", "project_ref", help="Filter by project (name or UUID)")
|
|
60
|
+
@click.option("--status", "-s", help="Filter by status (active, completed, handoff_ready)")
|
|
61
|
+
@click.option("--source", help="Filter by source (claude_code, gemini, codex)")
|
|
62
|
+
@click.option("--limit", "-n", default=20, help="Max sessions to show")
|
|
63
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
64
|
+
def list_sessions(
|
|
65
|
+
project_ref: str | None,
|
|
66
|
+
status: str | None,
|
|
67
|
+
source: str | None,
|
|
68
|
+
limit: int,
|
|
69
|
+
json_format: bool,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""List sessions with optional filtering."""
|
|
72
|
+
project_id = resolve_project_ref(project_ref) if project_ref else None
|
|
73
|
+
manager = get_session_manager()
|
|
74
|
+
sessions_list = manager.list(
|
|
75
|
+
project_id=project_id,
|
|
76
|
+
status=status,
|
|
77
|
+
source=source,
|
|
78
|
+
limit=limit,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if json_format:
|
|
82
|
+
click.echo(json.dumps([s.to_dict() for s in sessions_list], indent=2, default=str))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if not sessions_list:
|
|
86
|
+
click.echo("No sessions found.")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
click.echo(f"Found {len(sessions_list)} sessions:\n")
|
|
90
|
+
for session in sessions_list:
|
|
91
|
+
status_icon = {
|
|
92
|
+
"active": "●",
|
|
93
|
+
"completed": "✓",
|
|
94
|
+
"handoff_ready": "→",
|
|
95
|
+
"expired": "○",
|
|
96
|
+
}.get(session.status, "?")
|
|
97
|
+
|
|
98
|
+
title = session.title or "(no title)"
|
|
99
|
+
if len(title) > 50:
|
|
100
|
+
title = title[:47] + "..."
|
|
101
|
+
|
|
102
|
+
cost_str = ""
|
|
103
|
+
if session.usage_total_cost_usd > 0:
|
|
104
|
+
cost_str = f"${session.usage_total_cost_usd:.2f}"
|
|
105
|
+
|
|
106
|
+
seq_str = f"#{session.seq_num}" if session.seq_num else ""
|
|
107
|
+
click.echo(
|
|
108
|
+
f"{status_icon} {seq_str:<5} {session.id[:8]} {session.source:<12} {title:<40} {cost_str}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@sessions.command("show")
|
|
113
|
+
@click.argument("session_id")
|
|
114
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
115
|
+
def show_session(session_id: str, json_format: bool) -> None:
|
|
116
|
+
"""Show details for a session."""
|
|
117
|
+
try:
|
|
118
|
+
session_id = resolve_session_id(session_id)
|
|
119
|
+
except click.ClickException as e:
|
|
120
|
+
raise SystemExit(1) from e
|
|
121
|
+
|
|
122
|
+
manager = get_session_manager()
|
|
123
|
+
session = manager.get(session_id)
|
|
124
|
+
|
|
125
|
+
if not session:
|
|
126
|
+
click.echo(f"Session not found: {session_id}", err=True)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if json_format:
|
|
130
|
+
click.echo(json.dumps(session.to_dict(), indent=2, default=str))
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
click.echo(f"Session: {session.id}")
|
|
134
|
+
click.echo(f"Status: {session.status}")
|
|
135
|
+
click.echo(f"Source: {session.source}")
|
|
136
|
+
click.echo(f"Project: {session.project_id}")
|
|
137
|
+
if session.title:
|
|
138
|
+
click.echo(f"Title: {session.title}")
|
|
139
|
+
if session.git_branch:
|
|
140
|
+
click.echo(f"Branch: {session.git_branch}")
|
|
141
|
+
click.echo(f"Created: {session.created_at}")
|
|
142
|
+
click.echo(f"Updated: {session.updated_at}")
|
|
143
|
+
if session.parent_session_id:
|
|
144
|
+
click.echo(f"Parent: {session.parent_session_id}")
|
|
145
|
+
if session.usage_input_tokens > 0 or session.usage_output_tokens > 0:
|
|
146
|
+
click.echo("\nUsage Stats:")
|
|
147
|
+
click.echo(f" Input Tokens: {session.usage_input_tokens}")
|
|
148
|
+
click.echo(f" Output Tokens: {session.usage_output_tokens}")
|
|
149
|
+
click.echo(f" Cache Write: {session.usage_cache_creation_tokens}")
|
|
150
|
+
click.echo(f" Cache Read: {session.usage_cache_read_tokens}")
|
|
151
|
+
click.echo(f" Total Cost: ${session.usage_total_cost_usd:.4f}")
|
|
152
|
+
|
|
153
|
+
if session.summary_markdown:
|
|
154
|
+
click.echo(f"\nSummary:\n{session.summary_markdown[:500]}")
|
|
155
|
+
if len(session.summary_markdown) > 500:
|
|
156
|
+
click.echo("...")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@sessions.command("messages")
|
|
160
|
+
@click.argument("session_id")
|
|
161
|
+
@click.option("--limit", "-n", default=50, help="Max messages to show")
|
|
162
|
+
@click.option("--role", "-r", help="Filter by role (user, assistant, tool)")
|
|
163
|
+
@click.option("--offset", "-o", default=0, help="Skip first N messages")
|
|
164
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
165
|
+
def show_messages(
|
|
166
|
+
session_id: str,
|
|
167
|
+
limit: int,
|
|
168
|
+
role: str | None,
|
|
169
|
+
offset: int,
|
|
170
|
+
json_format: bool,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Show messages for a session."""
|
|
173
|
+
try:
|
|
174
|
+
session_id = resolve_session_id(session_id)
|
|
175
|
+
except click.ClickException as e:
|
|
176
|
+
raise SystemExit(1) from e
|
|
177
|
+
|
|
178
|
+
session_manager = get_session_manager()
|
|
179
|
+
message_manager = get_message_manager()
|
|
180
|
+
|
|
181
|
+
# Resolve session ID
|
|
182
|
+
session = session_manager.get(session_id)
|
|
183
|
+
if not session:
|
|
184
|
+
click.echo(f"Session not found: {session_id}", err=True)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Fetch messages
|
|
188
|
+
messages = asyncio.run(
|
|
189
|
+
message_manager.get_messages(
|
|
190
|
+
session_id=session.id,
|
|
191
|
+
limit=limit,
|
|
192
|
+
offset=offset,
|
|
193
|
+
role=role,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if json_format:
|
|
198
|
+
click.echo(json.dumps(messages, indent=2, default=str))
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if not messages:
|
|
202
|
+
click.echo("No messages found.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
total = asyncio.run(message_manager.count_messages(session.id))
|
|
206
|
+
click.echo(f"Messages for session {session.id[:12]} ({len(messages)}/{total}):\n")
|
|
207
|
+
|
|
208
|
+
for msg in messages:
|
|
209
|
+
role_icon = {"user": "👤", "assistant": "🤖", "tool": "🔧"}.get(msg["role"], "?")
|
|
210
|
+
content = msg.get("content") or ""
|
|
211
|
+
|
|
212
|
+
if msg.get("tool_name"):
|
|
213
|
+
click.echo(f"{role_icon} [{msg['message_index']}] {msg['role']}: {msg['tool_name']}")
|
|
214
|
+
else:
|
|
215
|
+
# Truncate long content
|
|
216
|
+
if len(content) > 200:
|
|
217
|
+
content = content[:197] + "..."
|
|
218
|
+
click.echo(f"{role_icon} [{msg['message_index']}] {msg['role']}: {content}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@sessions.command("search")
|
|
222
|
+
@click.argument("query")
|
|
223
|
+
@click.option("--session", "-s", "session_id", help="Search within specific session")
|
|
224
|
+
@click.option("--project", "-p", "project_ref", help="Search within project (name or UUID)")
|
|
225
|
+
@click.option("--limit", "-n", default=20, help="Max results")
|
|
226
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
227
|
+
def search_messages(
|
|
228
|
+
query: str,
|
|
229
|
+
session_id: str | None,
|
|
230
|
+
project_ref: str | None,
|
|
231
|
+
limit: int,
|
|
232
|
+
json_format: bool,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Search messages across sessions."""
|
|
235
|
+
if session_id:
|
|
236
|
+
try:
|
|
237
|
+
session_id = resolve_session_id(session_id)
|
|
238
|
+
except click.ClickException as e:
|
|
239
|
+
raise SystemExit(1) from e
|
|
240
|
+
|
|
241
|
+
project_id = resolve_project_ref(project_ref) if project_ref else None
|
|
242
|
+
message_manager = get_message_manager()
|
|
243
|
+
|
|
244
|
+
results = asyncio.run(
|
|
245
|
+
message_manager.search_messages(
|
|
246
|
+
query_text=query,
|
|
247
|
+
limit=limit,
|
|
248
|
+
session_id=session_id,
|
|
249
|
+
project_id=project_id,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if json_format:
|
|
254
|
+
click.echo(json.dumps(results, indent=2, default=str))
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
if not results:
|
|
258
|
+
click.echo(f"No messages found matching '{query}'")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
click.echo(f"Found {len(results)} messages matching '{query}':\n")
|
|
262
|
+
|
|
263
|
+
for msg in results:
|
|
264
|
+
content = msg.get("content") or ""
|
|
265
|
+
if len(content) > 100:
|
|
266
|
+
content = content[:97] + "..."
|
|
267
|
+
|
|
268
|
+
session_short = msg["session_id"][:8]
|
|
269
|
+
role_icon = {"user": "👤", "assistant": "🤖", "tool": "🔧"}.get(msg["role"], "?")
|
|
270
|
+
click.echo(f"{role_icon} [{session_short}] {content}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@sessions.command("delete")
|
|
274
|
+
@click.argument("session_id")
|
|
275
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this session?")
|
|
276
|
+
def delete_session(session_id: str) -> None:
|
|
277
|
+
"""Delete a session."""
|
|
278
|
+
try:
|
|
279
|
+
session_id = resolve_session_id(session_id)
|
|
280
|
+
except click.ClickException as e:
|
|
281
|
+
raise SystemExit(1) from e
|
|
282
|
+
|
|
283
|
+
manager = get_session_manager()
|
|
284
|
+
session = manager.get(session_id)
|
|
285
|
+
if not session:
|
|
286
|
+
click.echo(f"Session not found: {session_id}", err=True)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
success = manager.delete(session.id)
|
|
290
|
+
if success:
|
|
291
|
+
click.echo(f"Deleted session: {session.id}")
|
|
292
|
+
else:
|
|
293
|
+
click.echo(f"Failed to delete session: {session.id}", err=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@sessions.command("stats")
|
|
297
|
+
@click.option("--project", "-p", "project_ref", help="Filter by project (name or UUID)")
|
|
298
|
+
def session_stats(project_ref: str | None) -> None:
|
|
299
|
+
"""Show session statistics."""
|
|
300
|
+
project_id = resolve_project_ref(project_ref) if project_ref else None
|
|
301
|
+
manager = get_session_manager()
|
|
302
|
+
message_manager = get_message_manager()
|
|
303
|
+
|
|
304
|
+
sessions_list = manager.list(project_id=project_id, limit=10000)
|
|
305
|
+
|
|
306
|
+
if not sessions_list:
|
|
307
|
+
click.echo("No sessions found.")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
# Count by status
|
|
311
|
+
by_status: dict[str, int] = {}
|
|
312
|
+
by_source: dict[str, int] = {}
|
|
313
|
+
|
|
314
|
+
for session in sessions_list:
|
|
315
|
+
by_status[session.status] = by_status.get(session.status, 0) + 1
|
|
316
|
+
by_source[session.source] = by_source.get(session.source, 0) + 1
|
|
317
|
+
|
|
318
|
+
# Get message counts
|
|
319
|
+
message_counts = asyncio.run(message_manager.get_all_counts())
|
|
320
|
+
total_messages = sum(message_counts.values())
|
|
321
|
+
|
|
322
|
+
click.echo("Session Statistics:")
|
|
323
|
+
click.echo(f" Total Sessions: {len(sessions_list)}")
|
|
324
|
+
click.echo(f" Total Messages: {total_messages}")
|
|
325
|
+
|
|
326
|
+
click.echo("\n By Status:")
|
|
327
|
+
for status, count in sorted(by_status.items()):
|
|
328
|
+
click.echo(f" {status}: {count}")
|
|
329
|
+
|
|
330
|
+
click.echo("\n By Source:")
|
|
331
|
+
for source, count in sorted(by_source.items()):
|
|
332
|
+
click.echo(f" {source}: {count}")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@sessions.command("create-handoff")
|
|
336
|
+
@click.option("--session-id", "-s", help="Session ID (defaults to current active session)")
|
|
337
|
+
@click.option("--compact", "-c", is_flag=True, default=False, help="Generate compact summary only")
|
|
338
|
+
@click.option(
|
|
339
|
+
"--full", "full_summary", is_flag=True, default=False, help="Generate full LLM summary only"
|
|
340
|
+
)
|
|
341
|
+
@click.option(
|
|
342
|
+
"--output",
|
|
343
|
+
type=click.Choice(["db", "file", "all"]),
|
|
344
|
+
default="all",
|
|
345
|
+
help="Where to save: db only, file only, or all (both)",
|
|
346
|
+
)
|
|
347
|
+
@click.option(
|
|
348
|
+
"--path",
|
|
349
|
+
"output_path",
|
|
350
|
+
default="~/.gobby/session_summaries/",
|
|
351
|
+
help="Directory path for file output",
|
|
352
|
+
)
|
|
353
|
+
@click.argument("notes", required=False)
|
|
354
|
+
def create_handoff(
|
|
355
|
+
session_id: str | None,
|
|
356
|
+
compact: bool,
|
|
357
|
+
full_summary: bool,
|
|
358
|
+
output: str,
|
|
359
|
+
output_path: str,
|
|
360
|
+
notes: str | None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Create handoff context for a session.
|
|
363
|
+
|
|
364
|
+
Extracts structured context from the session transcript:
|
|
365
|
+
- Active gobby-task
|
|
366
|
+
- TodoWrite state
|
|
367
|
+
- Files modified
|
|
368
|
+
- Git commits and status
|
|
369
|
+
- Initial goal
|
|
370
|
+
- Recent activity
|
|
371
|
+
|
|
372
|
+
Summary types:
|
|
373
|
+
- --compact: Fast structured extraction using TranscriptAnalyzer
|
|
374
|
+
- --full: LLM-powered comprehensive summary
|
|
375
|
+
- Neither flag: Generate both (default)
|
|
376
|
+
|
|
377
|
+
Output destinations:
|
|
378
|
+
- db: Save to database only
|
|
379
|
+
- file: Write to file only (in --path directory)
|
|
380
|
+
- all: Save to both database and file
|
|
381
|
+
|
|
382
|
+
File output: full summary saved as session_*.md, compact as session_compact_*.md.
|
|
383
|
+
|
|
384
|
+
If no session ID is provided, uses the current project's most recent active session.
|
|
385
|
+
"""
|
|
386
|
+
import subprocess # nosec B404 - subprocess needed for git commands
|
|
387
|
+
import time
|
|
388
|
+
from pathlib import Path
|
|
389
|
+
|
|
390
|
+
from gobby.mcp_proxy.tools.session_messages import _format_handoff_markdown
|
|
391
|
+
from gobby.sessions.analyzer import TranscriptAnalyzer
|
|
392
|
+
|
|
393
|
+
manager = get_session_manager()
|
|
394
|
+
|
|
395
|
+
# Find session
|
|
396
|
+
if session_id:
|
|
397
|
+
try:
|
|
398
|
+
session_id = resolve_session_id(session_id)
|
|
399
|
+
except click.ClickException as e:
|
|
400
|
+
raise SystemExit(1) from e
|
|
401
|
+
session = manager.get(session_id)
|
|
402
|
+
if not session:
|
|
403
|
+
click.echo(f"Session not found: {session_id}", err=True)
|
|
404
|
+
return
|
|
405
|
+
else:
|
|
406
|
+
# Get most recent active session
|
|
407
|
+
try:
|
|
408
|
+
session_id = resolve_session_id(None) # uses get_active_session_id internally
|
|
409
|
+
except click.ClickException as e:
|
|
410
|
+
raise SystemExit(1) from e
|
|
411
|
+
session = manager.get(session_id)
|
|
412
|
+
if not session:
|
|
413
|
+
click.echo(f"Session not found: {session_id}", err=True)
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# Check for transcript
|
|
417
|
+
if not session.jsonl_path:
|
|
418
|
+
click.echo(f"Session {session.id[:12]} has no transcript path.", err=True)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
path = Path(session.jsonl_path)
|
|
422
|
+
if not path.exists():
|
|
423
|
+
click.echo(f"Transcript file not found: {path}", err=True)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Read and parse transcript
|
|
427
|
+
turns = []
|
|
428
|
+
with open(path) as f:
|
|
429
|
+
for line_num, line in enumerate(f, start=1):
|
|
430
|
+
if line.strip():
|
|
431
|
+
try:
|
|
432
|
+
turns.append(json.loads(line))
|
|
433
|
+
except json.JSONDecodeError as e:
|
|
434
|
+
snippet = line[:50] + "..." if len(line) > 50 else line.strip()
|
|
435
|
+
click.echo(
|
|
436
|
+
f"Warning: Skipping malformed JSON at line {line_num}: {e} ({snippet})",
|
|
437
|
+
err=True,
|
|
438
|
+
)
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
if not turns:
|
|
442
|
+
click.echo("Transcript is empty.", err=True)
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
# Analyze transcript
|
|
446
|
+
analyzer = TranscriptAnalyzer()
|
|
447
|
+
handoff_ctx = analyzer.extract_handoff_context(turns)
|
|
448
|
+
|
|
449
|
+
# Determine the git working directory - prefer project repo_path, fall back to transcript parent
|
|
450
|
+
git_cwd = path.parent
|
|
451
|
+
if session.project_id:
|
|
452
|
+
from gobby.storage.projects import LocalProjectManager
|
|
453
|
+
|
|
454
|
+
project_manager = LocalProjectManager(LocalDatabase())
|
|
455
|
+
project = project_manager.get(session.project_id)
|
|
456
|
+
if project and project.repo_path:
|
|
457
|
+
project_repo = Path(project.repo_path)
|
|
458
|
+
if project_repo.exists():
|
|
459
|
+
git_cwd = project_repo
|
|
460
|
+
|
|
461
|
+
# Enrich with real-time git status
|
|
462
|
+
if not handoff_ctx.git_status:
|
|
463
|
+
try:
|
|
464
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
465
|
+
["git", "status", "--short"],
|
|
466
|
+
capture_output=True,
|
|
467
|
+
text=True,
|
|
468
|
+
timeout=5,
|
|
469
|
+
cwd=git_cwd,
|
|
470
|
+
)
|
|
471
|
+
handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
|
|
472
|
+
except Exception:
|
|
473
|
+
pass # nosec B110 - git status is optional
|
|
474
|
+
|
|
475
|
+
# Get recent git commits
|
|
476
|
+
try:
|
|
477
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
478
|
+
["git", "log", "--oneline", "-10", "--format=%H|%s"],
|
|
479
|
+
capture_output=True,
|
|
480
|
+
text=True,
|
|
481
|
+
timeout=5,
|
|
482
|
+
cwd=git_cwd,
|
|
483
|
+
)
|
|
484
|
+
if result.returncode == 0:
|
|
485
|
+
commits = []
|
|
486
|
+
for line in result.stdout.strip().split("\n"):
|
|
487
|
+
if "|" in line:
|
|
488
|
+
hash_val, message = line.split("|", 1)
|
|
489
|
+
commits.append({"hash": hash_val, "message": message})
|
|
490
|
+
if commits:
|
|
491
|
+
handoff_ctx.git_commits = commits
|
|
492
|
+
except Exception:
|
|
493
|
+
pass # nosec B110 - git log is optional
|
|
494
|
+
|
|
495
|
+
# Determine what to generate (neither flag = both)
|
|
496
|
+
generate_compact = not full_summary or compact # generate if --compact or neither flag
|
|
497
|
+
generate_full = not compact or full_summary # generate if --full or neither flag
|
|
498
|
+
|
|
499
|
+
# Generate content
|
|
500
|
+
compact_markdown = None
|
|
501
|
+
full_markdown = None
|
|
502
|
+
|
|
503
|
+
if generate_compact:
|
|
504
|
+
compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
|
|
505
|
+
|
|
506
|
+
if generate_full:
|
|
507
|
+
# Generate LLM-powered full summary
|
|
508
|
+
try:
|
|
509
|
+
from gobby.config.app import load_config
|
|
510
|
+
from gobby.llm.claude import ClaudeLLMProvider
|
|
511
|
+
from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
|
|
512
|
+
|
|
513
|
+
config = load_config()
|
|
514
|
+
provider = ClaudeLLMProvider(config)
|
|
515
|
+
transcript_parser = ClaudeTranscriptParser()
|
|
516
|
+
|
|
517
|
+
# Get prompt template from config
|
|
518
|
+
prompt_template = None
|
|
519
|
+
if hasattr(config, "session_summary") and config.session_summary:
|
|
520
|
+
prompt_template = getattr(config.session_summary, "prompt", None)
|
|
521
|
+
|
|
522
|
+
if not prompt_template:
|
|
523
|
+
click.echo(
|
|
524
|
+
"Warning: No prompt template configured. "
|
|
525
|
+
"Set 'session_summary.prompt' in ~/.gobby/config.yaml",
|
|
526
|
+
err=True,
|
|
527
|
+
)
|
|
528
|
+
# Only fail if --full was explicitly requested without --compact
|
|
529
|
+
if full_summary and not compact:
|
|
530
|
+
return
|
|
531
|
+
# Otherwise, skip full generation but continue with compact
|
|
532
|
+
else:
|
|
533
|
+
# Prepare context for LLM
|
|
534
|
+
last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
|
|
535
|
+
last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
|
|
536
|
+
|
|
537
|
+
context = {
|
|
538
|
+
"transcript_summary": _format_turns_for_llm(last_turns),
|
|
539
|
+
"last_messages": last_messages,
|
|
540
|
+
"git_status": handoff_ctx.git_status or "",
|
|
541
|
+
"file_changes": "",
|
|
542
|
+
"external_id": session.id[:12],
|
|
543
|
+
"session_id": session.id,
|
|
544
|
+
"session_source": session.source,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
import anyio
|
|
548
|
+
|
|
549
|
+
async def _generate() -> str:
|
|
550
|
+
return await provider.generate_summary(context, prompt_template=prompt_template)
|
|
551
|
+
|
|
552
|
+
full_markdown = anyio.run(_generate)
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
click.echo(f"Warning: Failed to generate full summary: {e}", err=True)
|
|
556
|
+
if full_summary and not compact:
|
|
557
|
+
# Only --full was requested and it failed
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
# Determine what to save
|
|
561
|
+
save_to_db = output in ("db", "all")
|
|
562
|
+
save_to_file = output in ("file", "all")
|
|
563
|
+
|
|
564
|
+
# Save to database - always save both compact and full when available
|
|
565
|
+
if save_to_db:
|
|
566
|
+
if compact_markdown:
|
|
567
|
+
manager.update_compact_markdown(session.id, compact_markdown)
|
|
568
|
+
click.echo(f"Saved compact to database: {len(compact_markdown)} chars")
|
|
569
|
+
if full_markdown:
|
|
570
|
+
manager.update_summary(session.id, summary_markdown=full_markdown)
|
|
571
|
+
click.echo(f"Saved full to database: {len(full_markdown)} chars")
|
|
572
|
+
|
|
573
|
+
# Save to file
|
|
574
|
+
files_written = []
|
|
575
|
+
if save_to_file:
|
|
576
|
+
try:
|
|
577
|
+
summary_dir = Path(output_path).expanduser()
|
|
578
|
+
summary_dir.mkdir(parents=True, exist_ok=True)
|
|
579
|
+
timestamp = int(time.time())
|
|
580
|
+
|
|
581
|
+
# Write full summary as session_*.md
|
|
582
|
+
if full_markdown:
|
|
583
|
+
full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
|
|
584
|
+
full_file.write_text(full_markdown, encoding="utf-8")
|
|
585
|
+
files_written.append(str(full_file))
|
|
586
|
+
click.echo(f"Saved full to file: {full_file}")
|
|
587
|
+
|
|
588
|
+
# Write compact summary as session_compact_*.md
|
|
589
|
+
if compact_markdown:
|
|
590
|
+
compact_file = summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
|
|
591
|
+
compact_file.write_text(compact_markdown, encoding="utf-8")
|
|
592
|
+
files_written.append(str(compact_file))
|
|
593
|
+
click.echo(f"Saved compact to file: {compact_file}")
|
|
594
|
+
|
|
595
|
+
except Exception as e:
|
|
596
|
+
click.echo(f"Error writing file: {e}", err=True)
|
|
597
|
+
|
|
598
|
+
# Output summary
|
|
599
|
+
summary_type = "none"
|
|
600
|
+
if compact_markdown and full_markdown:
|
|
601
|
+
summary_type = "both"
|
|
602
|
+
elif compact_markdown:
|
|
603
|
+
summary_type = "compact"
|
|
604
|
+
elif full_markdown:
|
|
605
|
+
summary_type = "full"
|
|
606
|
+
click.echo(f"\nCreated handoff context for session {session.id[:12]}")
|
|
607
|
+
click.echo(f" Type: {summary_type}")
|
|
608
|
+
click.echo(f" Output: {output}")
|
|
609
|
+
if compact_markdown:
|
|
610
|
+
click.echo(f" Compact length: {len(compact_markdown)} chars")
|
|
611
|
+
if full_markdown:
|
|
612
|
+
click.echo(f" Full length: {len(full_markdown)} chars")
|
|
613
|
+
click.echo(f" Active task: {'Yes' if handoff_ctx.active_gobby_task else 'No'}")
|
|
614
|
+
click.echo(f" Todo items: {len(handoff_ctx.todo_state)}")
|
|
615
|
+
click.echo(f" Files modified: {len(handoff_ctx.files_modified)}")
|
|
616
|
+
click.echo(f" Git commits: {len(handoff_ctx.git_commits)}")
|
|
617
|
+
click.echo(f" Initial goal: {'Yes' if handoff_ctx.initial_goal else 'No'}")
|
|
618
|
+
|
|
619
|
+
if notes:
|
|
620
|
+
click.echo(f" Notes: {notes[:50]}{'...' if len(notes) > 50 else ''}")
|
|
621
|
+
for file_path in files_written:
|
|
622
|
+
click.echo(f" File: {file_path}")
|