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
gobby/memory/manager.py CHANGED
@@ -1,21 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import mimetypes
5
4
  from datetime import UTC, datetime
6
- from pathlib import Path
7
5
  from typing import TYPE_CHECKING, Any
8
6
 
9
- from gobby.config.app import MemoryConfig
7
+ from gobby.config.persistence import MemoryConfig
10
8
  from gobby.memory.backends import get_backend
11
9
  from gobby.memory.context import build_memory_context
12
- from gobby.memory.protocol import MediaAttachment, MemoryBackendProtocol
10
+ from gobby.memory.ingestion import MultimodalIngestor
11
+ from gobby.memory.protocol import MemoryBackendProtocol
12
+ from gobby.memory.search.coordinator import SearchCoordinator
13
+ from gobby.memory.services.crossref import CrossrefService
13
14
  from gobby.storage.database import DatabaseProtocol
14
15
  from gobby.storage.memories import LocalMemoryManager, Memory
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from gobby.llm.service import LLMService
18
- from gobby.memory.search import SearchBackend
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -45,8 +45,25 @@ class MemoryManager:
45
45
  # The SQLiteBackend uses LocalMemoryManager internally
46
46
  self.storage = LocalMemoryManager(db)
47
47
 
48
- self._search_backend: SearchBackend | None = None
49
- self._search_backend_fitted = False
48
+ # Initialize extracted components
49
+ self._search_coordinator = SearchCoordinator(
50
+ storage=self.storage,
51
+ config=config,
52
+ db=db,
53
+ )
54
+
55
+ self._crossref_service = CrossrefService(
56
+ storage=self.storage,
57
+ config=config,
58
+ search_backend_getter=lambda: self._search_coordinator.search_backend,
59
+ ensure_fitted=self._search_coordinator.ensure_fitted,
60
+ )
61
+
62
+ self._multimodal_ingestor = MultimodalIngestor(
63
+ storage=self.storage,
64
+ backend=self._backend,
65
+ llm_service=llm_service,
66
+ )
50
67
 
51
68
  @property
52
69
  def llm_service(self) -> LLMService | None:
@@ -57,9 +74,11 @@ class MemoryManager:
57
74
  def llm_service(self, service: LLMService | None) -> None:
58
75
  """Set the LLM service for image description."""
59
76
  self._llm_service = service
77
+ # Keep multimodal ingestor in sync
78
+ self._multimodal_ingestor.llm_service = service
60
79
 
61
80
  @property
62
- def search_backend(self) -> SearchBackend:
81
+ def search_backend(self) -> Any:
63
82
  """
64
83
  Lazy-init search backend based on configuration.
65
84
 
@@ -67,50 +86,15 @@ class MemoryManager:
67
86
  - "tfidf" (default): Zero-dependency TF-IDF search
68
87
  - "text": Simple text substring matching
69
88
  """
70
- if self._search_backend is None:
71
- from gobby.memory.search import get_search_backend
72
-
73
- backend_type = getattr(self.config, "search_backend", "tfidf")
74
- logger.debug(f"Initializing search backend: {backend_type}")
75
-
76
- try:
77
- self._search_backend = get_search_backend(
78
- backend_type=backend_type,
79
- db=self.db,
80
- )
81
- except Exception as e:
82
- logger.warning(
83
- f"Failed to initialize {backend_type} backend: {e}. Falling back to tfidf"
84
- )
85
- self._search_backend = get_search_backend("tfidf")
86
-
87
- return self._search_backend
89
+ return self._search_coordinator.search_backend
88
90
 
89
91
  def _ensure_search_backend_fitted(self) -> None:
90
92
  """Ensure the search backend is fitted with current memories."""
91
- if self._search_backend_fitted:
92
- return
93
-
94
- backend = self.search_backend
95
- if not backend.needs_refit():
96
- self._search_backend_fitted = True
97
- return
98
-
99
- # Fit the backend with all memories
100
- memories = self.storage.list_memories(limit=10000)
101
- memory_tuples = [(m.id, m.content) for m in memories]
102
-
103
- try:
104
- backend.fit(memory_tuples)
105
- self._search_backend_fitted = True
106
- logger.info(f"Search backend fitted with {len(memory_tuples)} memories")
107
- except Exception as e:
108
- logger.error(f"Failed to fit search backend: {e}")
109
- raise
93
+ self._search_coordinator.ensure_fitted()
110
94
 
111
95
  def mark_search_refit_needed(self) -> None:
112
96
  """Mark that the search backend needs to be refitted."""
113
- self._search_backend_fitted = False
97
+ self._search_coordinator.mark_refit_needed()
114
98
 
115
99
  def reindex_search(self) -> dict[str, Any]:
116
100
  """
@@ -125,36 +109,7 @@ class MemoryManager:
125
109
  Returns:
126
110
  Dict with index statistics including memory_count, backend_type, etc.
127
111
  """
128
- # Get all memories
129
- memories = self.storage.list_memories(limit=10000)
130
- memory_tuples = [(m.id, m.content) for m in memories]
131
-
132
- # Force refit the backend
133
- backend = self.search_backend
134
- backend_type = getattr(self.config, "search_backend", "tfidf")
135
-
136
- try:
137
- backend.fit(memory_tuples)
138
- self._search_backend_fitted = True
139
-
140
- # Get backend stats
141
- stats = backend.get_stats() if hasattr(backend, "get_stats") else {}
142
-
143
- return {
144
- "success": True,
145
- "memory_count": len(memory_tuples),
146
- "backend_type": backend_type,
147
- "fitted": True,
148
- **stats,
149
- }
150
- except Exception as e:
151
- logger.error(f"Failed to reindex search backend: {e}")
152
- return {
153
- "success": False,
154
- "error": str(e),
155
- "memory_count": len(memory_tuples),
156
- "backend_type": backend_type,
157
- }
112
+ return self._search_coordinator.reindex()
158
113
 
159
114
  async def remember(
160
115
  self,
@@ -178,8 +133,17 @@ class MemoryManager:
178
133
  source_session_id: Origin session
179
134
  tags: Optional tags
180
135
  """
181
- # Future: Duplicate detection via embeddings or fuzzy match?
182
- # For now, rely on storage layer (which uses content-hash ID for dedup)
136
+ # Check for existing memory with same content to avoid duplicates.
137
+ # The storage layer also checks via content-hash ID, but this provides
138
+ # an additional safeguard against race conditions and project_id mismatches.
139
+ normalized_content = content.strip()
140
+ if self.storage.content_exists(normalized_content, project_id):
141
+ # Return existing memory by computing the same content-derived ID
142
+ # that the storage layer uses, avoiding reliance on search ordering
143
+ existing_memory = self.storage.get_memory_by_content(normalized_content, project_id)
144
+ if existing_memory:
145
+ logger.debug(f"Memory already exists: {existing_memory.id}")
146
+ return existing_memory
183
147
 
184
148
  memory = self.storage.create_memory(
185
149
  content=content,
@@ -197,7 +161,7 @@ class MemoryManager:
197
161
  # Auto cross-reference if enabled
198
162
  if getattr(self.config, "auto_crossref", False):
199
163
  try:
200
- self._create_crossrefs(memory)
164
+ await self._crossref_service.create_crossrefs(memory)
201
165
  except Exception as e:
202
166
  # Don't fail the remember if crossref fails
203
167
  logger.warning(f"Auto-crossref failed for {memory.id}: {e}")
@@ -238,73 +202,19 @@ class MemoryManager:
238
202
  Raises:
239
203
  ValueError: If LLM service is not configured or image not found
240
204
  """
241
- path = Path(image_path)
242
- if not path.exists():
243
- raise ValueError(f"Image not found: {image_path}")
244
-
245
- # Get LLM provider for image description
246
- if not self._llm_service:
247
- raise ValueError(
248
- "LLM service not configured. Pass llm_service to MemoryManager "
249
- "to enable remember_with_image."
250
- )
251
-
252
- provider = self._llm_service.get_default_provider()
253
-
254
- # Generate image description
255
- description = await provider.describe_image(image_path, context=context)
256
-
257
- # Determine MIME type
258
- mime_type, _ = mimetypes.guess_type(str(path))
259
- if not mime_type:
260
- mime_type = "application/octet-stream"
261
-
262
- # Create media attachment
263
- media = MediaAttachment(
264
- media_type="image",
265
- content_path=str(path.absolute()),
266
- mime_type=mime_type,
267
- description=description,
268
- description_model=provider.provider_name,
269
- )
270
-
271
- # Store memory with media attachment via backend
272
- record = await self._backend.create(
273
- content=description,
205
+ memory = await self._multimodal_ingestor.remember_with_image(
206
+ image_path=image_path,
207
+ context=context,
274
208
  memory_type=memory_type,
275
209
  importance=importance,
276
210
  project_id=project_id,
277
211
  source_type=source_type,
278
212
  source_session_id=source_session_id,
279
213
  tags=tags,
280
- media=[media],
281
214
  )
282
-
283
215
  # Mark search index for refit
284
216
  self.mark_search_refit_needed()
285
-
286
- # Return as Memory object for backward compatibility
287
- # Note: The backend returns MemoryRecord, but we need Memory
288
- memory = self.storage.get_memory(record.id)
289
- if memory is not None:
290
- return memory
291
-
292
- # Fallback: construct Memory from MemoryRecord if storage lookup fails
293
- # This can happen with synthetic records from failed backend calls
294
- return Memory(
295
- id=record.id,
296
- content=record.content,
297
- memory_type=record.memory_type,
298
- created_at=record.created_at.isoformat(),
299
- updated_at=record.updated_at.isoformat()
300
- if record.updated_at
301
- else record.created_at.isoformat(),
302
- project_id=record.project_id,
303
- source_type=record.source_type,
304
- source_session_id=record.source_session_id,
305
- importance=record.importance,
306
- tags=record.tags,
307
- )
217
+ return memory
308
218
 
309
219
  async def remember_screenshot(
310
220
  self,
@@ -339,31 +249,8 @@ class MemoryManager:
339
249
  Raises:
340
250
  ValueError: If LLM service is not configured or screenshot bytes are empty
341
251
  """
342
- if not screenshot_bytes:
343
- raise ValueError("Screenshot bytes cannot be empty")
344
-
345
- # Determine resources directory using centralized utility
346
- from datetime import datetime as dt
347
-
348
- from gobby.cli.utils import get_resources_dir
349
- from gobby.utils.project_context import get_project_context
350
-
351
- ctx = get_project_context()
352
- project_path = ctx.get("path") if ctx else None
353
- resources_dir = get_resources_dir(project_path)
354
-
355
- # Generate timestamp-based filename
356
- timestamp = dt.now().strftime("%Y%m%d_%H%M%S_%f")
357
- filename = f"screenshot_{timestamp}.png"
358
- filepath = resources_dir / filename
359
-
360
- # Write screenshot to file
361
- filepath.write_bytes(screenshot_bytes)
362
- logger.debug(f"Saved screenshot to {filepath}")
363
-
364
- # Delegate to remember_with_image
365
- return await self.remember_with_image(
366
- image_path=str(filepath),
252
+ memory = await self._multimodal_ingestor.remember_screenshot(
253
+ screenshot_bytes=screenshot_bytes,
367
254
  context=context,
368
255
  memory_type=memory_type,
369
256
  importance=importance,
@@ -372,8 +259,11 @@ class MemoryManager:
372
259
  source_session_id=source_session_id,
373
260
  tags=tags,
374
261
  )
262
+ # Mark search index for refit
263
+ self.mark_search_refit_needed()
264
+ return memory
375
265
 
376
- def _create_crossrefs(
266
+ async def _create_crossrefs(
377
267
  self,
378
268
  memory: Memory,
379
269
  threshold: float | None = None,
@@ -393,46 +283,13 @@ class MemoryManager:
393
283
  Returns:
394
284
  Number of cross-references created
395
285
  """
396
- # Get thresholds from config or use defaults
397
- if threshold is None:
398
- threshold = getattr(self.config, "crossref_threshold", None)
399
- if threshold is None:
400
- threshold = 0.3
401
- if max_links is None:
402
- max_links = getattr(self.config, "crossref_max_links", None)
403
- if max_links is None:
404
- max_links = 5
405
-
406
- # Ensure search backend is fitted
407
- self._ensure_search_backend_fitted()
408
-
409
- # Search for similar memories
410
- similar = self.search_backend.search(memory.content, top_k=max_links + 1)
411
-
412
- # Create cross-references
413
- created = 0
414
- for other_id, score in similar:
415
- # Skip self-reference
416
- if other_id == memory.id:
417
- continue
418
-
419
- # Skip below threshold
420
- if score < threshold:
421
- continue
422
-
423
- # Create the crossref
424
- self.storage.create_crossref(memory.id, other_id, score)
425
- created += 1
426
-
427
- if created >= max_links:
428
- break
429
-
430
- if created > 0:
431
- logger.debug(f"Created {created} crossrefs for memory {memory.id}")
432
-
433
- return created
286
+ return await self._crossref_service.create_crossrefs(
287
+ memory=memory,
288
+ threshold=threshold,
289
+ max_links=max_links,
290
+ )
434
291
 
435
- def get_related(
292
+ async def get_related(
436
293
  self,
437
294
  memory_id: str,
438
295
  limit: int = 5,
@@ -449,21 +306,12 @@ class MemoryManager:
449
306
  Returns:
450
307
  List of related Memory objects, sorted by similarity
451
308
  """
452
- crossrefs = self.storage.get_crossrefs(
453
- memory_id, limit=limit, min_similarity=min_similarity
309
+ return await self._crossref_service.get_related(
310
+ memory_id=memory_id,
311
+ limit=limit,
312
+ min_similarity=min_similarity,
454
313
  )
455
314
 
456
- # Get the actual Memory objects
457
- memories = []
458
- for ref in crossrefs:
459
- # Get the "other" memory in the relationship
460
- other_id = ref.target_id if ref.source_id == memory_id else ref.source_id
461
- memory = self.get_memory(other_id)
462
- if memory:
463
- memories.append(memory)
464
-
465
- return memories
466
-
467
315
  def recall(
468
316
  self,
469
317
  query: str | None = None,
@@ -546,80 +394,20 @@ class MemoryManager:
546
394
  Uses the new search backend by default (TF-IDF),
547
395
  falling back to legacy semantic search if configured.
548
396
  """
549
- # Determine search mode from config or parameters
550
- if search_mode is None:
551
- search_mode = getattr(self.config, "search_backend", "tfidf")
552
-
553
397
  # Legacy compatibility: use_semantic is deprecated
554
398
  if use_semantic is not None:
555
399
  logger.warning("use_semantic argument is deprecated and ignored")
556
400
 
557
- # Use the search backend
558
- try:
559
- self._ensure_search_backend_fitted()
560
- # Fetch more results to allow for filtering
561
- fetch_multiplier = 3 if (tags_all or tags_any or tags_none) else 2
562
- results = self.search_backend.search(query, top_k=limit * fetch_multiplier)
563
-
564
- # Get the actual Memory objects
565
- memory_ids = [mid for mid, _ in results]
566
- memories = []
567
- for mid in memory_ids:
568
- memory = self.get_memory(mid)
569
- if memory:
570
- # Apply filters
571
- if project_id and memory.project_id != project_id:
572
- if memory.project_id is not None: # Allow global memories
573
- continue
574
- if min_importance and memory.importance < min_importance:
575
- continue
576
- # Apply tag filters
577
- if not self._passes_tag_filter(memory, tags_all, tags_any, tags_none):
578
- continue
579
- memories.append(memory)
580
- if len(memories) >= limit:
581
- break
582
-
583
- return memories
584
-
585
- except Exception as e:
586
- logger.warning(f"Search backend failed, falling back to text search: {e}")
587
- # Fall back to text search with tag filtering
588
- memories = self.storage.search_memories(
589
- query_text=query,
590
- project_id=project_id,
591
- limit=limit * 2,
592
- tags_all=tags_all,
593
- tags_any=tags_any,
594
- tags_none=tags_none,
595
- )
596
- if min_importance:
597
- memories = [m for m in memories if m.importance >= min_importance]
598
- return memories[:limit]
599
-
600
- def _passes_tag_filter(
601
- self,
602
- memory: Memory,
603
- tags_all: list[str] | None = None,
604
- tags_any: list[str] | None = None,
605
- tags_none: list[str] | None = None,
606
- ) -> bool:
607
- """Check if a memory passes the tag filter criteria."""
608
- memory_tags = set(memory.tags) if memory.tags else set()
609
-
610
- # Check tags_all: memory must have ALL specified tags
611
- if tags_all and not set(tags_all).issubset(memory_tags):
612
- return False
613
-
614
- # Check tags_any: memory must have at least ONE specified tag
615
- if tags_any and not memory_tags.intersection(tags_any):
616
- return False
617
-
618
- # Check tags_none: memory must have NONE of the specified tags
619
- if tags_none and memory_tags.intersection(tags_none):
620
- return False
621
-
622
- return True
401
+ return self._search_coordinator.search(
402
+ query=query,
403
+ project_id=project_id,
404
+ limit=limit,
405
+ min_importance=min_importance,
406
+ search_mode=search_mode,
407
+ tags_all=tags_all,
408
+ tags_any=tags_any,
409
+ tags_none=tags_none,
410
+ )
623
411
 
624
412
  def recall_as_context(
625
413
  self,
@@ -30,10 +30,20 @@ __all__ = [
30
30
  "SearchBackend",
31
31
  "SearchResult",
32
32
  "TFIDFSearcher",
33
+ "SearchCoordinator",
33
34
  "get_search_backend",
34
35
  ]
35
36
 
36
37
 
38
+ # Lazy import for SearchCoordinator to avoid circular imports
39
+ def __getattr__(name: str) -> Any:
40
+ if name == "SearchCoordinator":
41
+ from gobby.memory.search.coordinator import SearchCoordinator
42
+
43
+ return SearchCoordinator
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
45
+
46
+
37
47
  def get_search_backend(
38
48
  backend_type: str,
39
49
  db: DatabaseProtocol | None = None,