gobby 0.2.6__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  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/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,221 @@
1
+ """Multimodal content ingestion for memory system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import mimetypes
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gobby.memory.protocol import MediaAttachment
11
+ from gobby.storage.memories import Memory
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.llm.service import LLMService
15
+ from gobby.memory.protocol import MemoryBackendProtocol
16
+ from gobby.storage.memories import LocalMemoryManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class MultimodalIngestor:
22
+ """
23
+ Handles ingestion of multimodal content (images, screenshots) into memory.
24
+
25
+ Extracts image handling from MemoryManager to provide focused
26
+ multimodal processing capabilities.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ storage: LocalMemoryManager,
32
+ backend: MemoryBackendProtocol,
33
+ llm_service: LLMService | None = None,
34
+ ):
35
+ """
36
+ Initialize the multimodal ingestor.
37
+
38
+ Args:
39
+ storage: Memory storage manager for persistence
40
+ backend: Memory backend protocol for creating records
41
+ llm_service: LLM service for image description
42
+ """
43
+ self._storage = storage
44
+ self._backend = backend
45
+ self._llm_service = llm_service
46
+
47
+ @property
48
+ def llm_service(self) -> LLMService | None:
49
+ """Get the LLM service for image description."""
50
+ return self._llm_service
51
+
52
+ @llm_service.setter
53
+ def llm_service(self, service: LLMService | None) -> None:
54
+ """Set the LLM service for image description."""
55
+ self._llm_service = service
56
+
57
+ async def remember_with_image(
58
+ self,
59
+ image_path: str,
60
+ context: str | None = None,
61
+ memory_type: str = "fact",
62
+ importance: float = 0.5,
63
+ project_id: str | None = None,
64
+ source_type: str = "user",
65
+ source_session_id: str | None = None,
66
+ tags: list[str] | None = None,
67
+ ) -> Memory:
68
+ """
69
+ Store a memory with an image attachment.
70
+
71
+ Uses the configured LLM provider to generate a description of the image,
72
+ then stores the memory with the description as content and the image
73
+ as a media attachment.
74
+
75
+ Args:
76
+ image_path: Path to the image file
77
+ context: Optional context to guide the image description
78
+ memory_type: Type of memory (fact, preference, etc)
79
+ importance: 0.0-1.0 importance score
80
+ project_id: Optional project context
81
+ source_type: Origin of memory
82
+ source_session_id: Origin session
83
+ tags: Optional tags
84
+
85
+ Returns:
86
+ The created Memory object
87
+
88
+ Raises:
89
+ ValueError: If LLM service is not configured or image not found
90
+ """
91
+ path = Path(image_path)
92
+ if not path.exists():
93
+ raise ValueError(f"Image not found: {image_path}")
94
+
95
+ # Get LLM provider for image description
96
+ if not self._llm_service:
97
+ raise ValueError(
98
+ "LLM service not configured. Pass llm_service to MemoryManager "
99
+ "to enable remember_with_image."
100
+ )
101
+
102
+ provider = self._llm_service.get_default_provider()
103
+
104
+ # Generate image description
105
+ description = await provider.describe_image(image_path, context=context)
106
+
107
+ # Determine MIME type
108
+ mime_type, _ = mimetypes.guess_type(str(path))
109
+ if not mime_type:
110
+ mime_type = "application/octet-stream"
111
+
112
+ # Create media attachment
113
+ media = MediaAttachment(
114
+ media_type="image",
115
+ content_path=str(path.absolute()),
116
+ mime_type=mime_type,
117
+ description=description,
118
+ description_model=provider.provider_name,
119
+ )
120
+
121
+ # Store memory with media attachment via backend
122
+ record = await self._backend.create(
123
+ content=description,
124
+ memory_type=memory_type,
125
+ importance=importance,
126
+ project_id=project_id,
127
+ source_type=source_type,
128
+ source_session_id=source_session_id,
129
+ tags=tags,
130
+ media=[media],
131
+ )
132
+
133
+ # Return as Memory object for backward compatibility
134
+ # Note: The backend returns MemoryRecord, but we need Memory
135
+ memory = self._storage.get_memory(record.id)
136
+ if memory is not None:
137
+ return memory
138
+
139
+ # Fallback: construct Memory from MemoryRecord if storage lookup fails
140
+ # This can happen with synthetic records from failed backend calls
141
+ return Memory(
142
+ id=record.id,
143
+ content=record.content,
144
+ memory_type=record.memory_type,
145
+ created_at=record.created_at.isoformat(),
146
+ updated_at=record.updated_at.isoformat()
147
+ if record.updated_at
148
+ else record.created_at.isoformat(),
149
+ project_id=record.project_id,
150
+ source_type=record.source_type,
151
+ source_session_id=record.source_session_id,
152
+ importance=record.importance,
153
+ tags=record.tags,
154
+ )
155
+
156
+ async def remember_screenshot(
157
+ self,
158
+ screenshot_bytes: bytes,
159
+ context: str | None = None,
160
+ memory_type: str = "observation",
161
+ importance: float = 0.5,
162
+ project_id: str | None = None,
163
+ source_type: str = "user",
164
+ source_session_id: str | None = None,
165
+ tags: list[str] | None = None,
166
+ ) -> Memory:
167
+ """
168
+ Store a memory from raw screenshot bytes.
169
+
170
+ Saves the screenshot to .gobby/resources/ with a timestamp-based filename,
171
+ then delegates to remember_with_image() for LLM description and storage.
172
+
173
+ Args:
174
+ screenshot_bytes: Raw PNG screenshot bytes (from Playwright/Puppeteer)
175
+ context: Optional context to guide the image description
176
+ memory_type: Type of memory (default: "observation")
177
+ importance: 0.0-1.0 importance score
178
+ project_id: Optional project context
179
+ source_type: Origin of memory
180
+ source_session_id: Origin session
181
+ tags: Optional tags
182
+
183
+ Returns:
184
+ The created Memory object
185
+
186
+ Raises:
187
+ ValueError: If LLM service is not configured or screenshot bytes are empty
188
+ """
189
+ if not screenshot_bytes:
190
+ raise ValueError("Screenshot bytes cannot be empty")
191
+
192
+ # Determine resources directory using centralized utility
193
+ from datetime import datetime as dt
194
+
195
+ from gobby.cli.utils import get_resources_dir
196
+ from gobby.utils.project_context import get_project_context
197
+
198
+ ctx = get_project_context()
199
+ project_path = ctx.get("path") if ctx else None
200
+ resources_dir = get_resources_dir(project_path)
201
+
202
+ # Generate timestamp-based filename
203
+ timestamp = dt.now().strftime("%Y%m%d_%H%M%S_%f")
204
+ filename = f"screenshot_{timestamp}.png"
205
+ filepath = resources_dir / filename
206
+
207
+ # Write screenshot to file
208
+ filepath.write_bytes(screenshot_bytes)
209
+ logger.debug(f"Saved screenshot to {filepath}")
210
+
211
+ # Delegate to remember_with_image
212
+ return await self.remember_with_image(
213
+ image_path=str(filepath),
214
+ context=context,
215
+ memory_type=memory_type,
216
+ importance=importance,
217
+ project_id=project_id,
218
+ source_type=source_type,
219
+ source_session_id=source_session_id,
220
+ tags=tags,
221
+ )
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,
@@ -206,7 +161,7 @@ class MemoryManager:
206
161
  # Auto cross-reference if enabled
207
162
  if getattr(self.config, "auto_crossref", False):
208
163
  try:
209
- self._create_crossrefs(memory)
164
+ await self._crossref_service.create_crossrefs(memory)
210
165
  except Exception as e:
211
166
  # Don't fail the remember if crossref fails
212
167
  logger.warning(f"Auto-crossref failed for {memory.id}: {e}")
@@ -247,73 +202,19 @@ class MemoryManager:
247
202
  Raises:
248
203
  ValueError: If LLM service is not configured or image not found
249
204
  """
250
- path = Path(image_path)
251
- if not path.exists():
252
- raise ValueError(f"Image not found: {image_path}")
253
-
254
- # Get LLM provider for image description
255
- if not self._llm_service:
256
- raise ValueError(
257
- "LLM service not configured. Pass llm_service to MemoryManager "
258
- "to enable remember_with_image."
259
- )
260
-
261
- provider = self._llm_service.get_default_provider()
262
-
263
- # Generate image description
264
- description = await provider.describe_image(image_path, context=context)
265
-
266
- # Determine MIME type
267
- mime_type, _ = mimetypes.guess_type(str(path))
268
- if not mime_type:
269
- mime_type = "application/octet-stream"
270
-
271
- # Create media attachment
272
- media = MediaAttachment(
273
- media_type="image",
274
- content_path=str(path.absolute()),
275
- mime_type=mime_type,
276
- description=description,
277
- description_model=provider.provider_name,
278
- )
279
-
280
- # Store memory with media attachment via backend
281
- record = await self._backend.create(
282
- content=description,
205
+ memory = await self._multimodal_ingestor.remember_with_image(
206
+ image_path=image_path,
207
+ context=context,
283
208
  memory_type=memory_type,
284
209
  importance=importance,
285
210
  project_id=project_id,
286
211
  source_type=source_type,
287
212
  source_session_id=source_session_id,
288
213
  tags=tags,
289
- media=[media],
290
214
  )
291
-
292
215
  # Mark search index for refit
293
216
  self.mark_search_refit_needed()
294
-
295
- # Return as Memory object for backward compatibility
296
- # Note: The backend returns MemoryRecord, but we need Memory
297
- memory = self.storage.get_memory(record.id)
298
- if memory is not None:
299
- return memory
300
-
301
- # Fallback: construct Memory from MemoryRecord if storage lookup fails
302
- # This can happen with synthetic records from failed backend calls
303
- return Memory(
304
- id=record.id,
305
- content=record.content,
306
- memory_type=record.memory_type,
307
- created_at=record.created_at.isoformat(),
308
- updated_at=record.updated_at.isoformat()
309
- if record.updated_at
310
- else record.created_at.isoformat(),
311
- project_id=record.project_id,
312
- source_type=record.source_type,
313
- source_session_id=record.source_session_id,
314
- importance=record.importance,
315
- tags=record.tags,
316
- )
217
+ return memory
317
218
 
318
219
  async def remember_screenshot(
319
220
  self,
@@ -348,31 +249,8 @@ class MemoryManager:
348
249
  Raises:
349
250
  ValueError: If LLM service is not configured or screenshot bytes are empty
350
251
  """
351
- if not screenshot_bytes:
352
- raise ValueError("Screenshot bytes cannot be empty")
353
-
354
- # Determine resources directory using centralized utility
355
- from datetime import datetime as dt
356
-
357
- from gobby.cli.utils import get_resources_dir
358
- from gobby.utils.project_context import get_project_context
359
-
360
- ctx = get_project_context()
361
- project_path = ctx.get("path") if ctx else None
362
- resources_dir = get_resources_dir(project_path)
363
-
364
- # Generate timestamp-based filename
365
- timestamp = dt.now().strftime("%Y%m%d_%H%M%S_%f")
366
- filename = f"screenshot_{timestamp}.png"
367
- filepath = resources_dir / filename
368
-
369
- # Write screenshot to file
370
- filepath.write_bytes(screenshot_bytes)
371
- logger.debug(f"Saved screenshot to {filepath}")
372
-
373
- # Delegate to remember_with_image
374
- return await self.remember_with_image(
375
- image_path=str(filepath),
252
+ memory = await self._multimodal_ingestor.remember_screenshot(
253
+ screenshot_bytes=screenshot_bytes,
376
254
  context=context,
377
255
  memory_type=memory_type,
378
256
  importance=importance,
@@ -381,8 +259,11 @@ class MemoryManager:
381
259
  source_session_id=source_session_id,
382
260
  tags=tags,
383
261
  )
262
+ # Mark search index for refit
263
+ self.mark_search_refit_needed()
264
+ return memory
384
265
 
385
- def _create_crossrefs(
266
+ async def _create_crossrefs(
386
267
  self,
387
268
  memory: Memory,
388
269
  threshold: float | None = None,
@@ -402,46 +283,13 @@ class MemoryManager:
402
283
  Returns:
403
284
  Number of cross-references created
404
285
  """
405
- # Get thresholds from config or use defaults
406
- if threshold is None:
407
- threshold = getattr(self.config, "crossref_threshold", None)
408
- if threshold is None:
409
- threshold = 0.3
410
- if max_links is None:
411
- max_links = getattr(self.config, "crossref_max_links", None)
412
- if max_links is None:
413
- max_links = 5
414
-
415
- # Ensure search backend is fitted
416
- self._ensure_search_backend_fitted()
417
-
418
- # Search for similar memories
419
- similar = self.search_backend.search(memory.content, top_k=max_links + 1)
420
-
421
- # Create cross-references
422
- created = 0
423
- for other_id, score in similar:
424
- # Skip self-reference
425
- if other_id == memory.id:
426
- continue
427
-
428
- # Skip below threshold
429
- if score < threshold:
430
- continue
431
-
432
- # Create the crossref
433
- self.storage.create_crossref(memory.id, other_id, score)
434
- created += 1
435
-
436
- if created >= max_links:
437
- break
438
-
439
- if created > 0:
440
- logger.debug(f"Created {created} crossrefs for memory {memory.id}")
441
-
442
- return created
286
+ return await self._crossref_service.create_crossrefs(
287
+ memory=memory,
288
+ threshold=threshold,
289
+ max_links=max_links,
290
+ )
443
291
 
444
- def get_related(
292
+ async def get_related(
445
293
  self,
446
294
  memory_id: str,
447
295
  limit: int = 5,
@@ -458,21 +306,12 @@ class MemoryManager:
458
306
  Returns:
459
307
  List of related Memory objects, sorted by similarity
460
308
  """
461
- crossrefs = self.storage.get_crossrefs(
462
- 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,
463
313
  )
464
314
 
465
- # Get the actual Memory objects
466
- memories = []
467
- for ref in crossrefs:
468
- # Get the "other" memory in the relationship
469
- other_id = ref.target_id if ref.source_id == memory_id else ref.source_id
470
- memory = self.get_memory(other_id)
471
- if memory:
472
- memories.append(memory)
473
-
474
- return memories
475
-
476
315
  def recall(
477
316
  self,
478
317
  query: str | None = None,
@@ -555,80 +394,20 @@ class MemoryManager:
555
394
  Uses the new search backend by default (TF-IDF),
556
395
  falling back to legacy semantic search if configured.
557
396
  """
558
- # Determine search mode from config or parameters
559
- if search_mode is None:
560
- search_mode = getattr(self.config, "search_backend", "tfidf")
561
-
562
397
  # Legacy compatibility: use_semantic is deprecated
563
398
  if use_semantic is not None:
564
399
  logger.warning("use_semantic argument is deprecated and ignored")
565
400
 
566
- # Use the search backend
567
- try:
568
- self._ensure_search_backend_fitted()
569
- # Fetch more results to allow for filtering
570
- fetch_multiplier = 3 if (tags_all or tags_any or tags_none) else 2
571
- results = self.search_backend.search(query, top_k=limit * fetch_multiplier)
572
-
573
- # Get the actual Memory objects
574
- memory_ids = [mid for mid, _ in results]
575
- memories = []
576
- for mid in memory_ids:
577
- memory = self.get_memory(mid)
578
- if memory:
579
- # Apply filters
580
- if project_id and memory.project_id != project_id:
581
- if memory.project_id is not None: # Allow global memories
582
- continue
583
- if min_importance and memory.importance < min_importance:
584
- continue
585
- # Apply tag filters
586
- if not self._passes_tag_filter(memory, tags_all, tags_any, tags_none):
587
- continue
588
- memories.append(memory)
589
- if len(memories) >= limit:
590
- break
591
-
592
- return memories
593
-
594
- except Exception as e:
595
- logger.warning(f"Search backend failed, falling back to text search: {e}")
596
- # Fall back to text search with tag filtering
597
- memories = self.storage.search_memories(
598
- query_text=query,
599
- project_id=project_id,
600
- limit=limit * 2,
601
- tags_all=tags_all,
602
- tags_any=tags_any,
603
- tags_none=tags_none,
604
- )
605
- if min_importance:
606
- memories = [m for m in memories if m.importance >= min_importance]
607
- return memories[:limit]
608
-
609
- def _passes_tag_filter(
610
- self,
611
- memory: Memory,
612
- tags_all: list[str] | None = None,
613
- tags_any: list[str] | None = None,
614
- tags_none: list[str] | None = None,
615
- ) -> bool:
616
- """Check if a memory passes the tag filter criteria."""
617
- memory_tags = set(memory.tags) if memory.tags else set()
618
-
619
- # Check tags_all: memory must have ALL specified tags
620
- if tags_all and not set(tags_all).issubset(memory_tags):
621
- return False
622
-
623
- # Check tags_any: memory must have at least ONE specified tag
624
- if tags_any and not memory_tags.intersection(tags_any):
625
- return False
626
-
627
- # Check tags_none: memory must have NONE of the specified tags
628
- if tags_none and memory_tags.intersection(tags_none):
629
- return False
630
-
631
- 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
+ )
632
411
 
633
412
  def recall_as_context(
634
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,