gobby 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,378 @@
1
+ """Local clone storage manager.
2
+
3
+ Manages local git clones for parallel development, distinct from worktrees.
4
+ Clones are full repository copies while worktrees share a single .git directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ from gobby.storage.database import DatabaseProtocol
16
+ from gobby.utils.id import generate_prefixed_id
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class CloneStatus(str, Enum):
22
+ """Clone status values."""
23
+
24
+ ACTIVE = "active"
25
+ SYNCING = "syncing"
26
+ STALE = "stale"
27
+ CLEANUP = "cleanup"
28
+
29
+
30
+ @dataclass
31
+ class Clone:
32
+ """Clone data model."""
33
+
34
+ id: str
35
+ project_id: str
36
+ branch_name: str
37
+ clone_path: str
38
+ base_branch: str
39
+ task_id: str | None
40
+ agent_session_id: str | None
41
+ status: str
42
+ remote_url: str | None
43
+ last_sync_at: str | None
44
+ cleanup_after: str | None
45
+ created_at: str
46
+ updated_at: str
47
+
48
+ @classmethod
49
+ def from_row(cls, row: Any) -> Clone:
50
+ """Create Clone from database row."""
51
+ return cls(
52
+ id=row["id"],
53
+ project_id=row["project_id"],
54
+ branch_name=row["branch_name"],
55
+ clone_path=row["clone_path"],
56
+ base_branch=row["base_branch"],
57
+ task_id=row["task_id"],
58
+ agent_session_id=row["agent_session_id"],
59
+ status=row["status"],
60
+ remote_url=row["remote_url"],
61
+ last_sync_at=row["last_sync_at"],
62
+ cleanup_after=row["cleanup_after"],
63
+ created_at=row["created_at"],
64
+ updated_at=row["updated_at"],
65
+ )
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ """Convert to dictionary."""
69
+ return {
70
+ "id": self.id,
71
+ "project_id": self.project_id,
72
+ "branch_name": self.branch_name,
73
+ "clone_path": self.clone_path,
74
+ "base_branch": self.base_branch,
75
+ "task_id": self.task_id,
76
+ "agent_session_id": self.agent_session_id,
77
+ "status": self.status,
78
+ "remote_url": self.remote_url,
79
+ "last_sync_at": self.last_sync_at,
80
+ "cleanup_after": self.cleanup_after,
81
+ "created_at": self.created_at,
82
+ "updated_at": self.updated_at,
83
+ }
84
+
85
+
86
+ class LocalCloneManager:
87
+ """Manager for local clone storage."""
88
+
89
+ def __init__(self, db: DatabaseProtocol):
90
+ """Initialize with database connection."""
91
+ self.db = db
92
+
93
+ def create(
94
+ self,
95
+ project_id: str,
96
+ branch_name: str,
97
+ clone_path: str,
98
+ base_branch: str = "main",
99
+ task_id: str | None = None,
100
+ agent_session_id: str | None = None,
101
+ remote_url: str | None = None,
102
+ cleanup_after: str | None = None,
103
+ ) -> Clone:
104
+ """
105
+ Create a new clone record.
106
+
107
+ Args:
108
+ project_id: Project ID
109
+ branch_name: Git branch name
110
+ clone_path: Absolute path to clone directory
111
+ base_branch: Base branch for the clone
112
+ task_id: Optional task ID to link
113
+ agent_session_id: Optional session ID that owns this clone
114
+ remote_url: Optional remote URL of the repository
115
+ cleanup_after: Optional ISO timestamp for automatic cleanup
116
+
117
+ Returns:
118
+ Created Clone instance
119
+ """
120
+ clone_id = generate_prefixed_id("clone", length=6)
121
+ now = datetime.now(UTC).isoformat()
122
+
123
+ self.db.execute(
124
+ """
125
+ INSERT INTO clones (
126
+ id, project_id, branch_name, clone_path, base_branch,
127
+ task_id, agent_session_id, status, remote_url,
128
+ last_sync_at, cleanup_after, created_at, updated_at
129
+ )
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131
+ """,
132
+ (
133
+ clone_id,
134
+ project_id,
135
+ branch_name,
136
+ clone_path,
137
+ base_branch,
138
+ task_id,
139
+ agent_session_id,
140
+ CloneStatus.ACTIVE.value,
141
+ remote_url,
142
+ None, # last_sync_at
143
+ cleanup_after,
144
+ now,
145
+ now,
146
+ ),
147
+ )
148
+
149
+ return Clone(
150
+ id=clone_id,
151
+ project_id=project_id,
152
+ branch_name=branch_name,
153
+ clone_path=clone_path,
154
+ base_branch=base_branch,
155
+ task_id=task_id,
156
+ agent_session_id=agent_session_id,
157
+ status=CloneStatus.ACTIVE.value,
158
+ remote_url=remote_url,
159
+ last_sync_at=None,
160
+ cleanup_after=cleanup_after,
161
+ created_at=now,
162
+ updated_at=now,
163
+ )
164
+
165
+ def get(self, clone_id: str) -> Clone | None:
166
+ """Get clone by ID."""
167
+ row = self.db.fetchone("SELECT * FROM clones WHERE id = ?", (clone_id,))
168
+ return Clone.from_row(row) if row else None
169
+
170
+ def get_by_task(self, task_id: str) -> Clone | None:
171
+ """Get clone linked to a task."""
172
+ row = self.db.fetchone("SELECT * FROM clones WHERE task_id = ?", (task_id,))
173
+ return Clone.from_row(row) if row else None
174
+
175
+ def get_by_path(self, clone_path: str) -> Clone | None:
176
+ """Get clone by path."""
177
+ row = self.db.fetchone("SELECT * FROM clones WHERE clone_path = ?", (clone_path,))
178
+ return Clone.from_row(row) if row else None
179
+
180
+ def get_by_branch(self, project_id: str, branch_name: str) -> Clone | None:
181
+ """Get clone by project and branch name."""
182
+ row = self.db.fetchone(
183
+ "SELECT * FROM clones WHERE project_id = ? AND branch_name = ?",
184
+ (project_id, branch_name),
185
+ )
186
+ return Clone.from_row(row) if row else None
187
+
188
+ def list_clones(
189
+ self,
190
+ project_id: str | None = None,
191
+ status: str | None = None,
192
+ agent_session_id: str | None = None,
193
+ limit: int = 50,
194
+ ) -> list[Clone]:
195
+ """
196
+ List clones with optional filters.
197
+
198
+ Args:
199
+ project_id: Filter by project
200
+ status: Filter by status
201
+ agent_session_id: Filter by owning session
202
+ limit: Maximum number of results
203
+
204
+ Returns:
205
+ List of Clone instances
206
+ """
207
+ conditions = []
208
+ params: list[Any] = []
209
+
210
+ if project_id:
211
+ conditions.append("project_id = ?")
212
+ params.append(project_id)
213
+ if status:
214
+ conditions.append("status = ?")
215
+ params.append(status)
216
+ if agent_session_id:
217
+ conditions.append("agent_session_id = ?")
218
+ params.append(agent_session_id)
219
+
220
+ where_clause = " AND ".join(conditions) if conditions else "1=1"
221
+ params.append(limit)
222
+
223
+ # nosec B608: where_clause built from hardcoded condition strings, values parameterized
224
+ rows = self.db.fetchall(
225
+ f"""
226
+ SELECT * FROM clones
227
+ WHERE {where_clause}
228
+ ORDER BY created_at DESC
229
+ LIMIT ?
230
+ """, # nosec B608
231
+ tuple(params),
232
+ )
233
+ return [Clone.from_row(row) for row in rows]
234
+
235
+ # Allowlist of valid clone column names to prevent SQL injection
236
+ _VALID_UPDATE_FIELDS = frozenset(
237
+ {
238
+ "branch_name",
239
+ "base_branch",
240
+ "clone_path",
241
+ "status",
242
+ "agent_session_id",
243
+ "task_id",
244
+ "remote_url",
245
+ "last_sync_at",
246
+ "cleanup_after",
247
+ "updated_at",
248
+ }
249
+ )
250
+
251
+ def update(self, clone_id: str, **fields: Any) -> Clone | None:
252
+ """
253
+ Update clone fields.
254
+
255
+ Args:
256
+ clone_id: Clone ID to update
257
+ **fields: Fields to update (must be valid column names)
258
+
259
+ Returns:
260
+ Updated Clone or None if not found
261
+
262
+ Raises:
263
+ ValueError: If any field name is not in the allowlist
264
+ """
265
+ if not fields:
266
+ return self.get(clone_id)
267
+
268
+ # Validate field names against allowlist to prevent SQL injection
269
+ invalid_fields = set(fields.keys()) - self._VALID_UPDATE_FIELDS
270
+ if invalid_fields:
271
+ raise ValueError(f"Invalid field names: {invalid_fields}")
272
+
273
+ # Add updated_at timestamp
274
+ fields["updated_at"] = datetime.now(UTC).isoformat()
275
+
276
+ # nosec B608: Fields validated against _VALID_UPDATE_FIELDS allowlist above
277
+ set_clause = ", ".join(f"{key} = ?" for key in fields.keys())
278
+ values = list(fields.values()) + [clone_id]
279
+
280
+ self.db.execute(
281
+ f"UPDATE clones SET {set_clause} WHERE id = ?", # nosec B608
282
+ tuple(values),
283
+ )
284
+
285
+ return self.get(clone_id)
286
+
287
+ def delete(self, clone_id: str) -> bool:
288
+ """
289
+ Delete clone record.
290
+
291
+ Args:
292
+ clone_id: Clone ID to delete
293
+
294
+ Returns:
295
+ True if deleted, False if not found
296
+ """
297
+ cursor = self.db.execute("DELETE FROM clones WHERE id = ?", (clone_id,))
298
+ return cursor.rowcount > 0
299
+
300
+ # Status transition methods
301
+
302
+ def mark_syncing(self, clone_id: str) -> Clone | None:
303
+ """
304
+ Mark clone as syncing.
305
+
306
+ Args:
307
+ clone_id: Clone ID
308
+
309
+ Returns:
310
+ Updated Clone or None if not found
311
+ """
312
+ return self.update(clone_id, status=CloneStatus.SYNCING.value)
313
+
314
+ def mark_stale(self, clone_id: str) -> Clone | None:
315
+ """
316
+ Mark clone as stale (inactive).
317
+
318
+ Args:
319
+ clone_id: Clone ID
320
+
321
+ Returns:
322
+ Updated Clone or None if not found
323
+ """
324
+ return self.update(clone_id, status=CloneStatus.STALE.value)
325
+
326
+ def mark_cleanup(self, clone_id: str) -> Clone | None:
327
+ """
328
+ Mark clone for cleanup.
329
+
330
+ Args:
331
+ clone_id: Clone ID
332
+
333
+ Returns:
334
+ Updated Clone or None if not found
335
+ """
336
+ return self.update(clone_id, status=CloneStatus.CLEANUP.value)
337
+
338
+ def record_sync(self, clone_id: str) -> Clone | None:
339
+ """
340
+ Record a sync operation on a clone.
341
+
342
+ Args:
343
+ clone_id: Clone ID
344
+
345
+ Returns:
346
+ Updated Clone or None if not found
347
+ """
348
+ now = datetime.now(UTC).isoformat()
349
+ return self.update(
350
+ clone_id,
351
+ status=CloneStatus.ACTIVE.value,
352
+ last_sync_at=now,
353
+ )
354
+
355
+ def claim(self, clone_id: str, session_id: str) -> Clone | None:
356
+ """
357
+ Claim a clone for an agent session.
358
+
359
+ Args:
360
+ clone_id: Clone ID
361
+ session_id: Session ID claiming ownership
362
+
363
+ Returns:
364
+ Updated Clone or None if not found
365
+ """
366
+ return self.update(clone_id, agent_session_id=session_id)
367
+
368
+ def release(self, clone_id: str) -> Clone | None:
369
+ """
370
+ Release a clone from its current owner.
371
+
372
+ Args:
373
+ clone_id: Clone ID
374
+
375
+ Returns:
376
+ Updated Clone or None if not found
377
+ """
378
+ return self.update(clone_id, agent_session_id=None)
gobby/storage/database.py CHANGED
@@ -21,7 +21,7 @@ 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
- return val.isoformat(" ")
24
+ return val.isoformat()
25
25
 
26
26
 
27
27
  def _adapt_date(val: date) -> str:
gobby/storage/memories.py CHANGED
@@ -138,10 +138,18 @@ class LocalMemoryManager:
138
138
  tags: list[str] | None = None,
139
139
  media: str | None = None,
140
140
  ) -> Memory:
141
+ # Validate that content is not empty
142
+ if not content or not content.strip():
143
+ logger.warning("Skipping memory creation: empty content provided")
144
+ raise ValueError("Memory content cannot be empty")
145
+
141
146
  now = datetime.now(UTC).isoformat()
142
- # Ensure consistent ID for same content/project to avoid dupes?
143
- # Actually random/content-based might be better. Let's use content.
144
- memory_id = generate_prefixed_id("mm", content + str(project_id))
147
+ # Normalize content for consistent ID generation (avoid duplicates from
148
+ # whitespace differences or project_id inconsistency)
149
+ normalized_content = content.strip()
150
+ project_str = project_id if project_id else ""
151
+ # Use delimiter to prevent collisions (e.g., "abc" + "def" vs "abcd" + "ef")
152
+ memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
145
153
 
146
154
  # Check if memory already exists to avoid duplicate insert errors
147
155
  existing_row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))
@@ -190,18 +198,40 @@ class LocalMemoryManager:
190
198
 
191
199
  def content_exists(self, content: str, project_id: str | None = None) -> bool:
192
200
  """Check if a memory with identical content already exists."""
193
- if project_id:
194
- row = self.db.fetchone(
195
- "SELECT 1 FROM memories WHERE content = ? AND project_id = ?",
196
- (content, project_id),
197
- )
198
- else:
199
- row = self.db.fetchone(
200
- "SELECT 1 FROM memories WHERE content = ? AND project_id IS NULL",
201
- (content,),
202
- )
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}")
206
+
207
+ # Check by ID (content-hash based) for consistent dedup
208
+ row = self.db.fetchone("SELECT 1 FROM memories WHERE id = ?", (memory_id,))
203
209
  return row is not None
204
210
 
211
+ 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.
213
+
214
+ This provides a reliable way to fetch an existing memory without
215
+ relying on search result ordering.
216
+
217
+ Args:
218
+ content: The exact content to look up (will be normalized)
219
+ project_id: Optional project ID for scoping
220
+
221
+ Returns:
222
+ The Memory object if found, None otherwise
223
+ """
224
+ # Normalize content same way as ID generation in create_memory
225
+ 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
234
+
205
235
  def update_memory(
206
236
  self,
207
237
  memory_id: str,