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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
49
|
-
self.
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
threshold
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
462
|
-
memory_id
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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,
|
gobby/memory/search/__init__.py
CHANGED
|
@@ -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,
|