gobby 0.2.7__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/adapters/claude_code.py +96 -35
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/clones/git.py +177 -0
- gobby/config/skills.py +31 -0
- gobby/hooks/event_handlers.py +109 -10
- gobby/hooks/hook_manager.py +19 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/registries.py +21 -4
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- 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 +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/extractor.py +15 -1
- gobby/runner.py +13 -0
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +23 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +43 -3
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/blocking.py +13 -1
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
- gobby/cli/tui.py +0 -34
- 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-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/runner.py
CHANGED
|
@@ -471,6 +471,19 @@ class GobbyRunner:
|
|
|
471
471
|
except (asyncio.CancelledError, TimeoutError):
|
|
472
472
|
pass
|
|
473
473
|
|
|
474
|
+
# Export memories to JSONL backup on shutdown
|
|
475
|
+
if self.memory_sync_manager:
|
|
476
|
+
try:
|
|
477
|
+
count = await asyncio.wait_for(
|
|
478
|
+
self.memory_sync_manager.export_to_files(), timeout=5.0
|
|
479
|
+
)
|
|
480
|
+
if count > 0:
|
|
481
|
+
logger.info(f"Shutdown memory backup: exported {count} memories")
|
|
482
|
+
except TimeoutError:
|
|
483
|
+
logger.warning("Memory backup on shutdown timed out")
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning(f"Memory backup on shutdown failed: {e}")
|
|
486
|
+
|
|
474
487
|
try:
|
|
475
488
|
await asyncio.wait_for(self.mcp_proxy.disconnect_all(), timeout=3.0)
|
|
476
489
|
except TimeoutError:
|
|
@@ -19,6 +19,44 @@ if TYPE_CHECKING:
|
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
|
+
# Map hook types to hookEventName for additionalContext
|
|
23
|
+
# Only these hook types support hookSpecificOutput in Claude Code
|
|
24
|
+
HOOK_EVENT_NAME_MAP: dict[str, str] = {
|
|
25
|
+
"pre-tool-use": "PreToolUse",
|
|
26
|
+
"post-tool-use": "PostToolUse",
|
|
27
|
+
"post-tool-use-failure": "PostToolUse",
|
|
28
|
+
"user-prompt-submit": "UserPromptSubmit",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _graceful_error_response(hook_type: str, error_msg: str) -> dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Create a graceful degradation response for hook errors.
|
|
35
|
+
|
|
36
|
+
Instead of returning HTTP 500 (which causes Claude Code to show a confusing
|
|
37
|
+
"hook failed" warning), return a successful response that:
|
|
38
|
+
1. Allows the tool to proceed (continue=True)
|
|
39
|
+
2. Explains the error via additionalContext (so agents understand what happened)
|
|
40
|
+
|
|
41
|
+
This prevents agents from being confused by non-fatal hook errors.
|
|
42
|
+
"""
|
|
43
|
+
response: dict[str, Any] = {
|
|
44
|
+
"continue": True,
|
|
45
|
+
"decision": "approve",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Add helpful context for supported hook types
|
|
49
|
+
hook_event_name = HOOK_EVENT_NAME_MAP.get(hook_type)
|
|
50
|
+
if hook_event_name:
|
|
51
|
+
response["hookSpecificOutput"] = {
|
|
52
|
+
"hookEventName": hook_event_name,
|
|
53
|
+
"additionalContext": (
|
|
54
|
+
f"Gobby hook error (non-fatal): {error_msg}. Tool execution will proceed normally."
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response
|
|
59
|
+
|
|
22
60
|
|
|
23
61
|
def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
24
62
|
"""
|
|
@@ -51,6 +89,7 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
|
51
89
|
start_time = time.perf_counter()
|
|
52
90
|
metrics.inc_counter("http_requests_total")
|
|
53
91
|
metrics.inc_counter("hooks_total")
|
|
92
|
+
hook_type: str | None = None # Track for error handling
|
|
54
93
|
|
|
55
94
|
try:
|
|
56
95
|
# Parse request
|
|
@@ -109,27 +148,35 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
|
109
148
|
return result
|
|
110
149
|
|
|
111
150
|
except ValueError as e:
|
|
151
|
+
# Invalid request - still return graceful response
|
|
112
152
|
metrics.inc_counter("hooks_failed_total")
|
|
113
153
|
logger.warning(
|
|
114
154
|
f"Invalid hook request: {hook_type}",
|
|
115
155
|
extra={"hook_type": hook_type, "error": str(e)},
|
|
116
156
|
)
|
|
117
|
-
|
|
157
|
+
return _graceful_error_response(hook_type, str(e))
|
|
118
158
|
|
|
119
159
|
except Exception as e:
|
|
160
|
+
# Hook execution error - return graceful response so tool proceeds
|
|
161
|
+
# This prevents confusing "hook failed" warnings in Claude Code
|
|
120
162
|
metrics.inc_counter("hooks_failed_total")
|
|
121
163
|
logger.error(
|
|
122
164
|
f"Hook execution failed: {hook_type}",
|
|
123
165
|
exc_info=True,
|
|
124
166
|
extra={"hook_type": hook_type},
|
|
125
167
|
)
|
|
126
|
-
|
|
168
|
+
return _graceful_error_response(hook_type, str(e))
|
|
127
169
|
|
|
128
170
|
except HTTPException:
|
|
171
|
+
# Re-raise 400 errors (bad request) - these are client errors
|
|
129
172
|
raise
|
|
130
173
|
except Exception as e:
|
|
174
|
+
# Outer exception - return graceful response to prevent CLI warning
|
|
131
175
|
metrics.inc_counter("hooks_failed_total")
|
|
132
176
|
logger.error("Hook endpoint error", exc_info=True)
|
|
133
|
-
|
|
177
|
+
if hook_type:
|
|
178
|
+
return _graceful_error_response(hook_type, str(e))
|
|
179
|
+
# Fallback: return basic success to prevent CLI hook failure
|
|
180
|
+
return {"continue": True, "decision": "approve"}
|
|
134
181
|
|
|
135
182
|
return router
|
gobby/servers/websocket.py
CHANGED
|
@@ -10,6 +10,7 @@ Local-first version: Authentication is optional (defaults to always-allow).
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
from collections.abc import Callable, Coroutine
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from datetime import UTC, datetime
|
|
@@ -21,6 +22,7 @@ from websockets.datastructures import Headers
|
|
|
21
22
|
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
|
|
22
23
|
from websockets.http11 import Response
|
|
23
24
|
|
|
25
|
+
from gobby.agents.registry import get_running_agent_registry
|
|
24
26
|
from gobby.mcp_proxy.manager import MCPClientManager
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
@@ -256,6 +258,9 @@ class WebSocketServer:
|
|
|
256
258
|
elif msg_type == "stop_request":
|
|
257
259
|
await self._handle_stop_request(websocket, data)
|
|
258
260
|
|
|
261
|
+
elif msg_type == "terminal_input":
|
|
262
|
+
await self._handle_terminal_input(websocket, data)
|
|
263
|
+
|
|
259
264
|
else:
|
|
260
265
|
logger.warning(f"Unknown message type: {msg_type}")
|
|
261
266
|
await self._send_error(websocket, f"Unknown message type: {msg_type}")
|
|
@@ -496,11 +501,62 @@ class WebSocketServer:
|
|
|
496
501
|
)
|
|
497
502
|
|
|
498
503
|
logger.info(f"Stop requested for session {session_id} via WebSocket")
|
|
499
|
-
|
|
500
504
|
except Exception as e:
|
|
501
505
|
logger.error(f"Error handling stop request: {e}")
|
|
502
506
|
await self._send_error(websocket, f"Failed to signal stop: {str(e)}")
|
|
503
507
|
|
|
508
|
+
async def _handle_terminal_input(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
509
|
+
"""
|
|
510
|
+
Handle terminal input for a running agent.
|
|
511
|
+
|
|
512
|
+
Message format:
|
|
513
|
+
{
|
|
514
|
+
"type": "terminal_input",
|
|
515
|
+
"run_id": "uuid",
|
|
516
|
+
"data": "raw input string"
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
websocket: Client WebSocket connection
|
|
521
|
+
data: Parsed terminal input message
|
|
522
|
+
"""
|
|
523
|
+
run_id = data.get("run_id")
|
|
524
|
+
input_data = data.get("data")
|
|
525
|
+
|
|
526
|
+
if not run_id or input_data is None:
|
|
527
|
+
# Don't send error for every keystroke if malformed, just log debug
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"Invalid terminal_input: run_id={run_id}, data_len={len(str(input_data)) if input_data else 0}"
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
if not isinstance(input_data, str):
|
|
534
|
+
# input_data must be a string to encode; log and skip non-strings
|
|
535
|
+
logger.debug(
|
|
536
|
+
f"Invalid terminal_input type: run_id={run_id}, data_type={type(input_data).__name__}"
|
|
537
|
+
)
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
registry = get_running_agent_registry()
|
|
541
|
+
# Look up by run_id
|
|
542
|
+
agent = registry.get(run_id)
|
|
543
|
+
|
|
544
|
+
if not agent:
|
|
545
|
+
# Be silent on missing agent to avoid spamming errors if frontend is out of sync
|
|
546
|
+
# or if agent just died.
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
if agent.master_fd is None:
|
|
550
|
+
logger.warning(f"Agent {run_id} has no PTY master_fd")
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
# Write key/input to PTY off the event loop
|
|
555
|
+
encoded_data = input_data.encode("utf-8")
|
|
556
|
+
await asyncio.to_thread(os.write, agent.master_fd, encoded_data)
|
|
557
|
+
except OSError as e:
|
|
558
|
+
logger.warning(f"Failed to write to agent {run_id} PTY: {e}")
|
|
559
|
+
|
|
504
560
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
505
561
|
"""
|
|
506
562
|
Broadcast message to all connected clients.
|
gobby/sessions/analyzer.py
CHANGED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
|
-
from datetime import datetime
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
15
|
from gobby.sessions.transcripts.base import TranscriptParser
|
|
@@ -207,7 +207,7 @@ class TranscriptAnalyzer:
|
|
|
207
207
|
context.git_commits.append(
|
|
208
208
|
{
|
|
209
209
|
"command": command,
|
|
210
|
-
"timestamp": datetime.now().isoformat(), # Approx time
|
|
210
|
+
"timestamp": datetime.now(UTC).isoformat(), # Approx time
|
|
211
211
|
}
|
|
212
212
|
)
|
|
213
213
|
|
gobby/sessions/manager.py
CHANGED
|
@@ -82,6 +82,8 @@ class SessionManager:
|
|
|
82
82
|
git_branch: str | None = None,
|
|
83
83
|
project_path: str | None = None,
|
|
84
84
|
terminal_context: dict[str, Any] | None = None,
|
|
85
|
+
workflow_name: str | None = None,
|
|
86
|
+
agent_depth: int = 0,
|
|
85
87
|
) -> str:
|
|
86
88
|
"""
|
|
87
89
|
Register new session with local storage.
|
|
@@ -96,6 +98,9 @@ class SessionManager:
|
|
|
96
98
|
title: Optional session title/summary
|
|
97
99
|
git_branch: Optional git branch name
|
|
98
100
|
project_path: Optional project path (for git extraction if git_branch not provided)
|
|
101
|
+
terminal_context: Optional terminal context for correlation
|
|
102
|
+
workflow_name: Optional workflow to auto-activate for this session
|
|
103
|
+
agent_depth: Depth in agent hierarchy (0 = root session)
|
|
99
104
|
|
|
100
105
|
Returns:
|
|
101
106
|
session_id (database UUID)
|
|
@@ -125,6 +130,8 @@ class SessionManager:
|
|
|
125
130
|
git_branch=git_branch,
|
|
126
131
|
parent_session_id=parent_session_id,
|
|
127
132
|
terminal_context=terminal_context,
|
|
133
|
+
workflow_name=workflow_name,
|
|
134
|
+
agent_depth=agent_depth,
|
|
128
135
|
)
|
|
129
136
|
|
|
130
137
|
session_id: str = session.id
|
|
@@ -143,6 +150,8 @@ class SessionManager:
|
|
|
143
150
|
"project_id": project_id,
|
|
144
151
|
"title": title,
|
|
145
152
|
"git_branch": git_branch,
|
|
153
|
+
"workflow_name": workflow_name,
|
|
154
|
+
"agent_depth": agent_depth,
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
self.logger.debug(f"Registered session {session_id} (external_id={external_id})")
|
|
@@ -38,13 +38,34 @@ class GeminiTranscriptParser:
|
|
|
38
38
|
) -> list[dict[str, Any]]:
|
|
39
39
|
"""
|
|
40
40
|
Extract last N user<>agent message pairs.
|
|
41
|
+
|
|
42
|
+
Handles both Gemini CLI's type-based format and legacy role/content format.
|
|
41
43
|
"""
|
|
42
44
|
messages: list[dict[str, str]] = []
|
|
43
45
|
for turn in reversed(turns):
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
role
|
|
47
|
-
content
|
|
46
|
+
# Handle Gemini CLI's type-based format
|
|
47
|
+
event_type = turn.get("type")
|
|
48
|
+
role: str | None = None
|
|
49
|
+
content: str | Any = None
|
|
50
|
+
|
|
51
|
+
if event_type == "message":
|
|
52
|
+
role = turn.get("role")
|
|
53
|
+
content = turn.get("content")
|
|
54
|
+
elif event_type in ("init", "result"):
|
|
55
|
+
# Skip non-message events
|
|
56
|
+
continue
|
|
57
|
+
elif event_type == "tool_use":
|
|
58
|
+
# Include tool calls as assistant messages
|
|
59
|
+
role = "assistant"
|
|
60
|
+
tool_name = turn.get("tool_name") or turn.get("function_name", "unknown")
|
|
61
|
+
content = f"[Tool call: {tool_name}]"
|
|
62
|
+
elif event_type == "tool_result":
|
|
63
|
+
# Skip tool results for message extraction
|
|
64
|
+
continue
|
|
65
|
+
else:
|
|
66
|
+
# Fallback: legacy format with role/content at top level
|
|
67
|
+
role = turn.get("role") or turn.get("message", {}).get("role")
|
|
68
|
+
content = turn.get("content") or turn.get("message", {}).get("content")
|
|
48
69
|
|
|
49
70
|
if role in ["user", "model", "assistant"]:
|
|
50
71
|
norm_role = "assistant" if role == "model" else role
|
|
@@ -53,7 +74,7 @@ class GeminiTranscriptParser:
|
|
|
53
74
|
if isinstance(content, list):
|
|
54
75
|
content = " ".join(str(part) for part in content)
|
|
55
76
|
|
|
56
|
-
messages.insert(0, {"role": norm_role, "content": str(content)})
|
|
77
|
+
messages.insert(0, {"role": norm_role, "content": str(content or "")})
|
|
57
78
|
if len(messages) >= num_pairs * 2:
|
|
58
79
|
break
|
|
59
80
|
return messages
|
|
@@ -78,6 +99,13 @@ class GeminiTranscriptParser:
|
|
|
78
99
|
def parse_line(self, line: str, index: int) -> ParsedMessage | None:
|
|
79
100
|
"""
|
|
80
101
|
Parse a single line from the transcript JSONL.
|
|
102
|
+
|
|
103
|
+
Gemini CLI uses type-based events in JSONL format:
|
|
104
|
+
- {"type":"init", "session_id":"...", "model":"...", "timestamp":"..."}
|
|
105
|
+
- {"type":"message", "role":"user"|"model", "content":"...", ...}
|
|
106
|
+
- {"type":"tool_use", "tool_name":"Bash", "parameters":{...}, ...}
|
|
107
|
+
- {"type":"tool_result", "tool_id":"...", "status":"success", "output":"...", ...}
|
|
108
|
+
- {"type":"result", "status":"success", "stats":{...}, ...}
|
|
81
109
|
"""
|
|
82
110
|
if not line.strip():
|
|
83
111
|
return None
|
|
@@ -95,45 +123,83 @@ class GeminiTranscriptParser:
|
|
|
95
123
|
except ValueError:
|
|
96
124
|
timestamp = datetime.now(UTC)
|
|
97
125
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
role = data.get("role")
|
|
101
|
-
content = data.get("content")
|
|
126
|
+
# Handle Gemini CLI's type-based event format
|
|
127
|
+
event_type = data.get("type")
|
|
102
128
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
# Initialize defaults
|
|
130
|
+
role: str | None = None
|
|
131
|
+
content: str | Any = ""
|
|
132
|
+
content_type = "text"
|
|
133
|
+
tool_name: str | None = None
|
|
134
|
+
tool_input: dict[str, Any] | None = None
|
|
135
|
+
tool_result: dict[str, Any] | None = None
|
|
107
136
|
|
|
108
|
-
if
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
if msg_type == "user":
|
|
112
|
-
role = "user"
|
|
113
|
-
elif msg_type == "model":
|
|
114
|
-
role = "assistant"
|
|
137
|
+
if event_type == "init":
|
|
138
|
+
# Session initialization event - skip or treat as system
|
|
139
|
+
return None
|
|
115
140
|
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
elif event_type == "message":
|
|
142
|
+
# Message event with role (user/model)
|
|
143
|
+
role = data.get("role")
|
|
144
|
+
content = data.get("content", "")
|
|
145
|
+
|
|
146
|
+
elif event_type in ("user", "model"):
|
|
147
|
+
# Role specified directly in type field
|
|
148
|
+
role = event_type
|
|
149
|
+
content = data.get("content", "")
|
|
150
|
+
|
|
151
|
+
elif event_type == "tool_use":
|
|
152
|
+
# Tool invocation event
|
|
118
153
|
role = "assistant"
|
|
154
|
+
content_type = "tool_use"
|
|
155
|
+
tool_name = data.get("tool_name") or data.get("function_name")
|
|
156
|
+
tool_input = data.get("parameters") or data.get("args") or data.get("input")
|
|
157
|
+
content = f"Tool call: {tool_name}"
|
|
158
|
+
|
|
159
|
+
elif event_type == "tool_result":
|
|
160
|
+
# Tool result event
|
|
161
|
+
role = "tool"
|
|
162
|
+
content_type = "tool_result"
|
|
163
|
+
tool_name = data.get("tool_name")
|
|
164
|
+
output = data.get("output") or data.get("result") or ""
|
|
165
|
+
tool_result = {"output": output, "status": data.get("status", "unknown")}
|
|
166
|
+
content = str(output)[:500] if output else "" # Truncate long outputs
|
|
167
|
+
|
|
168
|
+
elif event_type == "result":
|
|
169
|
+
# Final result event - skip
|
|
170
|
+
return None
|
|
119
171
|
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
|
|
172
|
+
else:
|
|
173
|
+
# Fallback: try legacy format with role/content at top level
|
|
174
|
+
role = data.get("role")
|
|
175
|
+
content = data.get("content")
|
|
176
|
+
|
|
177
|
+
# Check nested message structure
|
|
178
|
+
if not role and "message" in data:
|
|
179
|
+
msg = data["message"]
|
|
180
|
+
role = msg.get("role")
|
|
181
|
+
content = msg.get("content")
|
|
182
|
+
|
|
183
|
+
# Handle tool_result at top level (legacy)
|
|
184
|
+
if not role and "tool_result" in data:
|
|
123
185
|
role = "tool"
|
|
186
|
+
content_type = "tool_result"
|
|
124
187
|
content = str(data["tool_result"])
|
|
125
|
-
|
|
126
|
-
|
|
188
|
+
|
|
189
|
+
# If still no role in fallback, skip this line
|
|
190
|
+
if not role:
|
|
127
191
|
return None
|
|
128
192
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
193
|
+
# Validate role is set - skip lines with missing role
|
|
194
|
+
if not role:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# Normalize role: model -> assistant
|
|
198
|
+
if role == "model":
|
|
199
|
+
role = "assistant"
|
|
134
200
|
|
|
201
|
+
# Normalize content - handle list content (rich parts)
|
|
135
202
|
if isinstance(content, list):
|
|
136
|
-
# Handle potential rich content
|
|
137
203
|
text_parts: list[str] = []
|
|
138
204
|
for part in content:
|
|
139
205
|
if isinstance(part, str):
|
|
@@ -141,7 +207,7 @@ class GeminiTranscriptParser:
|
|
|
141
207
|
elif isinstance(part, dict):
|
|
142
208
|
if "text" in part:
|
|
143
209
|
text_parts.append(str(part["text"]))
|
|
144
|
-
# Check for tool calls
|
|
210
|
+
# Check for tool calls embedded in content
|
|
145
211
|
if "functionCall" in part:
|
|
146
212
|
content_type = "tool_use"
|
|
147
213
|
tool_name = part["functionCall"].get("name")
|
gobby/storage/database.py
CHANGED
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import weakref
|
|
12
12
|
from collections.abc import Iterator
|
|
13
13
|
from contextlib import AbstractContextManager, contextmanager
|
|
14
|
-
from datetime import date, datetime
|
|
14
|
+
from datetime import UTC, date, datetime
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
17
17
|
|
|
@@ -21,6 +21,9 @@ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
|
21
21
|
|
|
22
22
|
def _adapt_datetime(val: datetime) -> str:
|
|
23
23
|
"""Adapt datetime to ISO format string for SQLite storage."""
|
|
24
|
+
# If naive datetime, assume UTC and add timezone info for RFC3339 compliance
|
|
25
|
+
if val.tzinfo is None:
|
|
26
|
+
val = val.replace(tzinfo=UTC)
|
|
24
27
|
return val.isoformat()
|
|
25
28
|
|
|
26
29
|
|
|
@@ -31,7 +34,11 @@ def _adapt_date(val: date) -> str:
|
|
|
31
34
|
|
|
32
35
|
def _convert_datetime(val: bytes) -> datetime:
|
|
33
36
|
"""Convert SQLite datetime string back to datetime object."""
|
|
34
|
-
|
|
37
|
+
dt = datetime.fromisoformat(val.decode())
|
|
38
|
+
# Ensure timezone-aware (treat naive as UTC) for consistency
|
|
39
|
+
if dt.tzinfo is None:
|
|
40
|
+
dt = dt.replace(tzinfo=UTC)
|
|
41
|
+
return dt
|
|
35
42
|
|
|
36
43
|
|
|
37
44
|
def _convert_date(val: bytes) -> date:
|
gobby/storage/memories.py
CHANGED
|
@@ -197,40 +197,51 @@ class LocalMemoryManager:
|
|
|
197
197
|
return row is not None
|
|
198
198
|
|
|
199
199
|
def content_exists(self, content: str, project_id: str | None = None) -> bool:
|
|
200
|
-
"""Check if a memory with identical content already exists.
|
|
201
|
-
# Normalize content same way as ID generation in create_memory
|
|
202
|
-
normalized_content = content.strip()
|
|
203
|
-
project_str = project_id if project_id else ""
|
|
204
|
-
# Use delimiter to match create_memory ID generation
|
|
205
|
-
memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
|
|
200
|
+
"""Check if a memory with identical content already exists.
|
|
206
201
|
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
Uses global deduplication - checks if any memory has the same content,
|
|
203
|
+
regardless of project_id. This prevents duplicates when the same content
|
|
204
|
+
is stored with different or NULL project_ids.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
content: The content to check for
|
|
208
|
+
project_id: Ignored (kept for backward compatibility)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if a memory with identical content exists
|
|
212
|
+
"""
|
|
213
|
+
# Global deduplication: check by content directly, ignoring project_id
|
|
214
|
+
# This fixes the duplicate issue where same content + different project_id
|
|
215
|
+
# would create different memory IDs
|
|
216
|
+
normalized_content = content.strip()
|
|
217
|
+
row = self.db.fetchone(
|
|
218
|
+
"SELECT 1 FROM memories WHERE content = ? LIMIT 1",
|
|
219
|
+
(normalized_content,),
|
|
220
|
+
)
|
|
209
221
|
return row is not None
|
|
210
222
|
|
|
211
223
|
def get_memory_by_content(self, content: str, project_id: str | None = None) -> Memory | None:
|
|
212
|
-
"""Get a memory by its exact content
|
|
224
|
+
"""Get a memory by its exact content.
|
|
213
225
|
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
Uses global lookup - finds any memory with matching content regardless
|
|
227
|
+
of project_id. This matches the behavior of content_exists().
|
|
216
228
|
|
|
217
229
|
Args:
|
|
218
230
|
content: The exact content to look up (will be normalized)
|
|
219
|
-
project_id:
|
|
231
|
+
project_id: Ignored (kept for backward compatibility)
|
|
220
232
|
|
|
221
233
|
Returns:
|
|
222
234
|
The Memory object if found, None otherwise
|
|
223
235
|
"""
|
|
224
|
-
#
|
|
236
|
+
# Global lookup: find by content directly, ignoring project_id
|
|
225
237
|
normalized_content = content.strip()
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
return None
|
|
238
|
+
row = self.db.fetchone(
|
|
239
|
+
"SELECT * FROM memories WHERE content = ? LIMIT 1",
|
|
240
|
+
(normalized_content,),
|
|
241
|
+
)
|
|
242
|
+
if row:
|
|
243
|
+
return Memory.from_row(row)
|
|
244
|
+
return None
|
|
234
245
|
|
|
235
246
|
def update_memory(
|
|
236
247
|
self,
|
gobby/storage/migrations.py
CHANGED
|
@@ -43,11 +43,11 @@ class MigrationUnsupportedError(Exception):
|
|
|
43
43
|
# Migration can be SQL string or a callable that takes LocalDatabase
|
|
44
44
|
MigrationAction = str | Callable[[LocalDatabase], None]
|
|
45
45
|
|
|
46
|
-
# Baseline version - the schema state
|
|
47
|
-
#
|
|
48
|
-
BASELINE_VERSION =
|
|
46
|
+
# Baseline version - the schema state at v78 (flattened)
|
|
47
|
+
# This is applied for new databases directly
|
|
48
|
+
BASELINE_VERSION = 78
|
|
49
49
|
|
|
50
|
-
# Baseline schema - flattened from
|
|
50
|
+
# Baseline schema - flattened from v78 production state, includes hub tracking fields
|
|
51
51
|
# This is applied for new databases directly
|
|
52
52
|
# Generated by: sqlite3 ~/.gobby/gobby-hub.db .schema
|
|
53
53
|
BASELINE_SCHEMA = """
|
|
@@ -583,6 +583,9 @@ CREATE TABLE skills (
|
|
|
583
583
|
source_path TEXT,
|
|
584
584
|
source_type TEXT,
|
|
585
585
|
source_ref TEXT,
|
|
586
|
+
hub_name TEXT,
|
|
587
|
+
hub_slug TEXT,
|
|
588
|
+
hub_version TEXT,
|
|
586
589
|
enabled INTEGER DEFAULT 1,
|
|
587
590
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
588
591
|
created_at TEXT NOT NULL,
|
|
@@ -692,11 +695,27 @@ def _migrate_backfill_session_seq_num_per_project(db: LocalDatabase) -> None:
|
|
|
692
695
|
logger.info(f"Re-numbered {updated} sessions with per-project seq_num")
|
|
693
696
|
|
|
694
697
|
|
|
698
|
+
def _migrate_add_hub_tracking_to_skills(db: LocalDatabase) -> None:
|
|
699
|
+
"""Add hub tracking fields to skills table.
|
|
700
|
+
|
|
701
|
+
Adds hub_name, hub_slug, and hub_version columns to track which hub
|
|
702
|
+
a skill was installed from.
|
|
703
|
+
"""
|
|
704
|
+
with db.transaction() as conn:
|
|
705
|
+
conn.execute("ALTER TABLE skills ADD COLUMN hub_name TEXT")
|
|
706
|
+
conn.execute("ALTER TABLE skills ADD COLUMN hub_slug TEXT")
|
|
707
|
+
conn.execute("ALTER TABLE skills ADD COLUMN hub_version TEXT")
|
|
708
|
+
|
|
709
|
+
logger.info("Added hub tracking fields to skills table")
|
|
710
|
+
|
|
711
|
+
|
|
695
712
|
MIGRATIONS: list[tuple[int, str, MigrationAction]] = [
|
|
696
713
|
# Project-scoped session refs: Change seq_num index from global to project-scoped
|
|
697
714
|
(76, "Make sessions.seq_num project-scoped", _migrate_session_seq_num_project_scoped),
|
|
698
715
|
# Project-scoped session refs: Re-backfill seq_num per project
|
|
699
716
|
(77, "Backfill sessions.seq_num per project", _migrate_backfill_session_seq_num_per_project),
|
|
717
|
+
# Hub tracking: Add hub_name, hub_slug, hub_version to skills table
|
|
718
|
+
(78, "Add hub tracking fields to skills", _migrate_add_hub_tracking_to_skills),
|
|
700
719
|
]
|
|
701
720
|
|
|
702
721
|
|
gobby/storage/sessions.py
CHANGED
|
@@ -166,6 +166,7 @@ class LocalSessionManager:
|
|
|
166
166
|
agent_depth: int = 0,
|
|
167
167
|
spawned_by_agent_id: str | None = None,
|
|
168
168
|
terminal_context: dict[str, Any] | None = None,
|
|
169
|
+
workflow_name: str | None = None,
|
|
169
170
|
) -> Session:
|
|
170
171
|
"""
|
|
171
172
|
Register a new session or return existing one.
|
|
@@ -241,9 +242,9 @@ class LocalSessionManager:
|
|
|
241
242
|
id, external_id, machine_id, source, project_id, title,
|
|
242
243
|
jsonl_path, git_branch, parent_session_id,
|
|
243
244
|
agent_depth, spawned_by_agent_id, terminal_context,
|
|
244
|
-
status, created_at, updated_at, seq_num, had_edits
|
|
245
|
+
workflow_name, status, created_at, updated_at, seq_num, had_edits
|
|
245
246
|
)
|
|
246
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 0)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 0)
|
|
247
248
|
""",
|
|
248
249
|
(
|
|
249
250
|
session_id,
|
|
@@ -258,6 +259,7 @@ class LocalSessionManager:
|
|
|
258
259
|
agent_depth,
|
|
259
260
|
spawned_by_agent_id,
|
|
260
261
|
json.dumps(terminal_context) if terminal_context else None,
|
|
262
|
+
workflow_name,
|
|
261
263
|
now,
|
|
262
264
|
now,
|
|
263
265
|
next_seq_num,
|