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.
Files changed (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {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
- raise HTTPException(status_code=400, detail=str(e)) from e
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
- raise HTTPException(status_code=500, detail=str(e)) from e
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
- raise HTTPException(status_code=500, detail=str(e)) from e
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
@@ -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.
@@ -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
- # Adapt to generic turn structure
45
- # Assumes generic schema: {"role": "...", "content": "..."} or nested in "message"
46
- role = turn.get("role") or turn.get("message", {}).get("role")
47
- content = turn.get("content") or turn.get("message", {}).get("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
- # Determine role and content
99
- # Check top-level or nested 'message'
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
- if not role and "message" in data:
104
- msg = data["message"]
105
- role = msg.get("role")
106
- content = msg.get("content")
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 not role:
109
- # Try type field common in other schemas
110
- msg_type = data.get("type")
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
- # Normalize role
117
- if role == "model":
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
- if not role:
121
- # Maybe a tool result or system event
122
- if "tool_result" in data:
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
- else:
126
- # Unknown or uninteresting line
188
+
189
+ # If still no role in fallback, skip this line
190
+ if not role:
127
191
  return None
128
192
 
129
- # Normalize content
130
- content_type = "text"
131
- tool_name = None
132
- tool_input = None
133
- tool_result = None
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
- return datetime.fromisoformat(val.decode())
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
- # Check by ID (content-hash based) for consistent dedup
208
- row = self.db.fetchone("SELECT 1 FROM memories WHERE id = ?", (memory_id,))
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, using the content-derived ID.
224
+ """Get a memory by its exact content.
213
225
 
214
- This provides a reliable way to fetch an existing memory without
215
- relying on search result ordering.
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: Optional project ID for scoping
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
- # Normalize content same way as ID generation in create_memory
236
+ # Global lookup: find by content directly, ignoring project_id
225
237
  normalized_content = content.strip()
226
- project_str = project_id if project_id else ""
227
- # Use delimiter to match create_memory ID generation
228
- memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
229
-
230
- try:
231
- return self.get_memory(memory_id)
232
- except ValueError:
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,
@@ -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 after all legacy migrations
47
- # Baseline version - the schema state at v75 (flattened)
48
- BASELINE_VERSION = 75
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 v75 production state
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,