gobby 0.2.7__py3-none-any.whl → 0.2.9__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/claude_code.py +99 -61
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/app_context.py +59 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/cli/utils.py +5 -17
- gobby/clones/git.py +177 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +87 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +573 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/hook_manager.py +21 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/claude.py +377 -42
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/registries.py +35 -4
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +46 -12
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- 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 +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
- gobby/mcp_proxy/tools/workflows/_query.py +207 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/extractor.py +15 -1
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +36 -10
- gobby/servers/http.py +186 -149
- gobby/servers/routes/admin.py +12 -0
- gobby/servers/routes/mcp/endpoints/execution.py +15 -7
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +4 -4
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +46 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +87 -7
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +5 -0
- gobby/workflows/context_actions.py +21 -24
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +109 -1
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/engine.py +96 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/lifecycle_evaluator.py +2 -1
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
- gobby/cli/tui.py +0 -34
- gobby/hooks/event_handlers.py +0 -909
- gobby/mcp_proxy/tools/workflows.py +0 -973
- 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-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal tools for workflows.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess # nosec B404
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from gobby.paths import get_install_dir
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def close_terminal(
|
|
19
|
+
signal: str = "TERM",
|
|
20
|
+
delay_ms: int = 0,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""
|
|
23
|
+
Close the current terminal by running the agent shutdown script.
|
|
24
|
+
|
|
25
|
+
This is for agent self-termination (meeseeks-style). The agent calls
|
|
26
|
+
this to close its own terminal window when done with its workflow.
|
|
27
|
+
|
|
28
|
+
The script is located at ~/.gobby/scripts/agent_shutdown.sh and is
|
|
29
|
+
automatically rebuilt if missing. It handles different terminal types
|
|
30
|
+
(tmux, iTerm, Terminal.app, Ghostty, Kitty, WezTerm, etc.).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
signal: Signal to use for shutdown (TERM, KILL, INT). Default: TERM.
|
|
34
|
+
delay_ms: Optional delay in milliseconds before shutdown. Default: 0.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dict with success status and message.
|
|
38
|
+
"""
|
|
39
|
+
# Script location
|
|
40
|
+
gobby_dir = Path.home() / ".gobby"
|
|
41
|
+
scripts_dir = gobby_dir / "scripts"
|
|
42
|
+
script_path = scripts_dir / "agent_shutdown.sh"
|
|
43
|
+
|
|
44
|
+
# Source script from the install directory (single source of truth)
|
|
45
|
+
source_script_path = get_install_dir() / "shared" / "scripts" / "agent_shutdown.sh"
|
|
46
|
+
|
|
47
|
+
def get_script_version(script_content: str) -> str | None:
|
|
48
|
+
"""Extract VERSION marker from script content."""
|
|
49
|
+
import re
|
|
50
|
+
|
|
51
|
+
match = re.search(r"^# VERSION:\s*(.+)$", script_content, re.MULTILINE)
|
|
52
|
+
return match.group(1).strip() if match else None
|
|
53
|
+
|
|
54
|
+
# Ensure directories exist and script is present/up-to-date
|
|
55
|
+
script_rebuilt = False
|
|
56
|
+
try:
|
|
57
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# Read source script content
|
|
60
|
+
if source_script_path.exists():
|
|
61
|
+
source_content = source_script_path.read_text()
|
|
62
|
+
source_version = get_script_version(source_content)
|
|
63
|
+
else:
|
|
64
|
+
logger.warning(f"Source shutdown script not found at {source_script_path}")
|
|
65
|
+
source_content = None
|
|
66
|
+
source_version = None
|
|
67
|
+
|
|
68
|
+
# Check if installed script exists and compare versions
|
|
69
|
+
needs_rebuild = False
|
|
70
|
+
if not script_path.exists():
|
|
71
|
+
needs_rebuild = True
|
|
72
|
+
elif source_content:
|
|
73
|
+
installed_content = script_path.read_text()
|
|
74
|
+
installed_version = get_script_version(installed_content)
|
|
75
|
+
# Rebuild if versions differ or installed has no version marker
|
|
76
|
+
if installed_version != source_version:
|
|
77
|
+
needs_rebuild = True
|
|
78
|
+
logger.info(
|
|
79
|
+
f"Shutdown script version mismatch: installed={installed_version}, source={source_version}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if needs_rebuild:
|
|
83
|
+
if not source_content:
|
|
84
|
+
logger.error(
|
|
85
|
+
f"Cannot rebuild shutdown script at {script_path}: "
|
|
86
|
+
f"source script not found at {source_script_path}"
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
"success": False,
|
|
90
|
+
"error": f"Source shutdown script not found at {source_script_path}",
|
|
91
|
+
}
|
|
92
|
+
script_path.write_text(source_content)
|
|
93
|
+
# Make executable
|
|
94
|
+
script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
|
|
95
|
+
script_rebuilt = True
|
|
96
|
+
logger.info(f"Created/updated agent shutdown script at {script_path}")
|
|
97
|
+
except OSError as e:
|
|
98
|
+
return {
|
|
99
|
+
"success": False,
|
|
100
|
+
"error": f"Failed to create shutdown script: {e}",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Validate signal
|
|
104
|
+
valid_signals = {"TERM", "KILL", "INT", "HUP", "QUIT"}
|
|
105
|
+
if signal.upper() not in valid_signals:
|
|
106
|
+
return {
|
|
107
|
+
"success": False,
|
|
108
|
+
"error": f"Invalid signal '{signal}'. Valid: {valid_signals}",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Apply delay before launching script (non-blocking)
|
|
112
|
+
if delay_ms > 0:
|
|
113
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
114
|
+
|
|
115
|
+
# Launch the script
|
|
116
|
+
try:
|
|
117
|
+
# Run in background - we don't wait for it since it kills our process
|
|
118
|
+
env = os.environ.copy()
|
|
119
|
+
|
|
120
|
+
subprocess.Popen( # nosec B603 - script path is from gobby scripts directory
|
|
121
|
+
[str(script_path), signal.upper(), "0"], # Delay already applied
|
|
122
|
+
env=env,
|
|
123
|
+
start_new_session=True, # Detach from parent
|
|
124
|
+
stdout=subprocess.DEVNULL,
|
|
125
|
+
stderr=subprocess.DEVNULL,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"success": True,
|
|
130
|
+
"message": "Shutdown script launched",
|
|
131
|
+
"script_path": str(script_path),
|
|
132
|
+
"script_rebuilt": script_rebuilt,
|
|
133
|
+
"signal": signal.upper(),
|
|
134
|
+
}
|
|
135
|
+
except OSError as e:
|
|
136
|
+
return {
|
|
137
|
+
"success": False,
|
|
138
|
+
"error": f"Failed to launch shutdown script: {e}",
|
|
139
|
+
}
|
|
@@ -287,6 +287,7 @@ def create_worktrees_registry(
|
|
|
287
287
|
worktree_storage: LocalWorktreeManager,
|
|
288
288
|
git_manager: WorktreeGitManager | None = None,
|
|
289
289
|
project_id: str | None = None,
|
|
290
|
+
session_manager: Any | None = None,
|
|
290
291
|
) -> InternalToolRegistry:
|
|
291
292
|
"""
|
|
292
293
|
Create a worktree tool registry with all worktree-related tools.
|
|
@@ -295,10 +296,20 @@ def create_worktrees_registry(
|
|
|
295
296
|
worktree_storage: LocalWorktreeManager for database operations.
|
|
296
297
|
git_manager: WorktreeGitManager for git operations.
|
|
297
298
|
project_id: Default project ID for operations.
|
|
299
|
+
session_manager: Session manager for resolving session references.
|
|
298
300
|
|
|
299
301
|
Returns:
|
|
300
302
|
InternalToolRegistry with all worktree tools registered.
|
|
301
303
|
"""
|
|
304
|
+
|
|
305
|
+
def _resolve_session_id(ref: str) -> str:
|
|
306
|
+
"""Resolve session reference (#N, N, UUID, or prefix) to UUID."""
|
|
307
|
+
if session_manager is None:
|
|
308
|
+
return ref # No resolution available, return as-is
|
|
309
|
+
ctx = get_project_context()
|
|
310
|
+
proj_id = ctx.get("id") if ctx else project_id
|
|
311
|
+
return str(session_manager.resolve_session_reference(ref, proj_id))
|
|
312
|
+
|
|
302
313
|
registry = InternalToolRegistry(
|
|
303
314
|
name="gobby-worktrees",
|
|
304
315
|
description="Git worktree management - create, manage, and cleanup isolated development directories",
|
|
@@ -435,7 +446,7 @@ def create_worktrees_registry(
|
|
|
435
446
|
|
|
436
447
|
@registry.tool(
|
|
437
448
|
name="list_worktrees",
|
|
438
|
-
description="List worktrees with optional filters.",
|
|
449
|
+
description="List worktrees with optional filters. Accepts #N, N, UUID, or prefix for agent_session_id.",
|
|
439
450
|
)
|
|
440
451
|
async def list_worktrees(
|
|
441
452
|
status: str | None = None,
|
|
@@ -447,16 +458,24 @@ def create_worktrees_registry(
|
|
|
447
458
|
|
|
448
459
|
Args:
|
|
449
460
|
status: Filter by status (active, stale, merged, abandoned).
|
|
450
|
-
agent_session_id:
|
|
461
|
+
agent_session_id: Session reference (accepts #N, N, UUID, or prefix) to filter by owning session.
|
|
451
462
|
limit: Maximum results (default: 50).
|
|
452
463
|
|
|
453
464
|
Returns:
|
|
454
465
|
Dict with list of worktrees.
|
|
455
466
|
"""
|
|
467
|
+
# Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
|
|
468
|
+
resolved_session_id = agent_session_id
|
|
469
|
+
if agent_session_id:
|
|
470
|
+
try:
|
|
471
|
+
resolved_session_id = _resolve_session_id(agent_session_id)
|
|
472
|
+
except ValueError as e:
|
|
473
|
+
return {"success": False, "error": str(e)}
|
|
474
|
+
|
|
456
475
|
worktrees = worktree_storage.list_worktrees(
|
|
457
476
|
project_id=project_id,
|
|
458
477
|
status=status,
|
|
459
|
-
agent_session_id=
|
|
478
|
+
agent_session_id=resolved_session_id,
|
|
460
479
|
limit=limit,
|
|
461
480
|
)
|
|
462
481
|
|
|
@@ -479,7 +498,7 @@ def create_worktrees_registry(
|
|
|
479
498
|
|
|
480
499
|
@registry.tool(
|
|
481
500
|
name="claim_worktree",
|
|
482
|
-
description="Claim ownership of a worktree for an agent session.",
|
|
501
|
+
description="Claim ownership of a worktree for an agent session. Accepts #N, N, UUID, or prefix for session_id.",
|
|
483
502
|
)
|
|
484
503
|
async def claim_worktree(
|
|
485
504
|
worktree_id: str,
|
|
@@ -490,11 +509,17 @@ def create_worktrees_registry(
|
|
|
490
509
|
|
|
491
510
|
Args:
|
|
492
511
|
worktree_id: The worktree ID to claim.
|
|
493
|
-
session_id:
|
|
512
|
+
session_id: Session reference (accepts #N, N, UUID, or prefix) claiming ownership.
|
|
494
513
|
|
|
495
514
|
Returns:
|
|
496
515
|
Dict with success status.
|
|
497
516
|
"""
|
|
517
|
+
# Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
|
|
518
|
+
try:
|
|
519
|
+
resolved_session_id = _resolve_session_id(session_id)
|
|
520
|
+
except ValueError as e:
|
|
521
|
+
return {"success": False, "error": str(e)}
|
|
522
|
+
|
|
498
523
|
worktree = worktree_storage.get(worktree_id)
|
|
499
524
|
if not worktree:
|
|
500
525
|
return {
|
|
@@ -502,13 +527,13 @@ def create_worktrees_registry(
|
|
|
502
527
|
"error": f"Worktree '{worktree_id}' not found",
|
|
503
528
|
}
|
|
504
529
|
|
|
505
|
-
if worktree.agent_session_id and worktree.agent_session_id !=
|
|
530
|
+
if worktree.agent_session_id and worktree.agent_session_id != resolved_session_id:
|
|
506
531
|
return {
|
|
507
532
|
"success": False,
|
|
508
533
|
"error": f"Worktree already claimed by session '{worktree.agent_session_id}'",
|
|
509
534
|
}
|
|
510
535
|
|
|
511
|
-
updated = worktree_storage.claim(worktree_id,
|
|
536
|
+
updated = worktree_storage.claim(worktree_id, resolved_session_id)
|
|
512
537
|
if not updated:
|
|
513
538
|
return {"error": "Failed to claim worktree"}
|
|
514
539
|
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component for handling Memory Manager's multimodal ingestion logic.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gobby.memory.ingestion import MultimodalIngestor
|
|
11
|
+
from gobby.memory.protocol import MemoryBackendProtocol
|
|
12
|
+
from gobby.storage.memories import LocalMemoryManager, Memory
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from gobby.llm.service import LLMService
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IngestionService:
|
|
21
|
+
"""Service for handling memory ingestion, particularly multimodal content."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
storage: LocalMemoryManager,
|
|
26
|
+
backend: MemoryBackendProtocol,
|
|
27
|
+
llm_service: LLMService | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.storage = storage
|
|
30
|
+
self._backend = backend
|
|
31
|
+
self._llm_service = llm_service
|
|
32
|
+
|
|
33
|
+
self._multimodal_ingestor = MultimodalIngestor(
|
|
34
|
+
storage=storage,
|
|
35
|
+
backend=backend,
|
|
36
|
+
llm_service=llm_service,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def llm_service(self) -> LLMService | None:
|
|
41
|
+
"""Get the LLM service."""
|
|
42
|
+
return self._llm_service
|
|
43
|
+
|
|
44
|
+
@llm_service.setter
|
|
45
|
+
def llm_service(self, service: LLMService | None) -> None:
|
|
46
|
+
"""Set the LLM service and propagate to ingestor."""
|
|
47
|
+
self._llm_service = service
|
|
48
|
+
self._multimodal_ingestor.llm_service = service
|
|
49
|
+
|
|
50
|
+
async def remember_with_image(
|
|
51
|
+
self,
|
|
52
|
+
image_path: str,
|
|
53
|
+
context: str | None = None,
|
|
54
|
+
memory_type: str = "fact",
|
|
55
|
+
importance: float = 0.5,
|
|
56
|
+
project_id: str | None = None,
|
|
57
|
+
source_type: str = "user",
|
|
58
|
+
source_session_id: str | None = None,
|
|
59
|
+
tags: list[str] | None = None,
|
|
60
|
+
) -> Memory:
|
|
61
|
+
"""
|
|
62
|
+
Store a memory with an image attachment.
|
|
63
|
+
"""
|
|
64
|
+
return await self._multimodal_ingestor.remember_with_image(
|
|
65
|
+
image_path=image_path,
|
|
66
|
+
context=context,
|
|
67
|
+
memory_type=memory_type,
|
|
68
|
+
importance=importance,
|
|
69
|
+
project_id=project_id,
|
|
70
|
+
source_type=source_type,
|
|
71
|
+
source_session_id=source_session_id,
|
|
72
|
+
tags=tags,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def remember_screenshot(
|
|
76
|
+
self,
|
|
77
|
+
screenshot_bytes: bytes,
|
|
78
|
+
context: str | None = None,
|
|
79
|
+
memory_type: str = "observation",
|
|
80
|
+
importance: float = 0.5,
|
|
81
|
+
project_id: str | None = None,
|
|
82
|
+
source_type: str = "user",
|
|
83
|
+
source_session_id: str | None = None,
|
|
84
|
+
tags: list[str] | None = None,
|
|
85
|
+
) -> Memory:
|
|
86
|
+
"""
|
|
87
|
+
Store a memory from raw screenshot bytes.
|
|
88
|
+
"""
|
|
89
|
+
return await self._multimodal_ingestor.remember_screenshot(
|
|
90
|
+
screenshot_bytes=screenshot_bytes,
|
|
91
|
+
context=context,
|
|
92
|
+
memory_type=memory_type,
|
|
93
|
+
importance=importance,
|
|
94
|
+
project_id=project_id,
|
|
95
|
+
source_type=source_type,
|
|
96
|
+
source_session_id=source_session_id,
|
|
97
|
+
tags=tags,
|
|
98
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component for handling Memory Manager's search and cross-referencing logic.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gobby.config.persistence import MemoryConfig
|
|
11
|
+
from gobby.memory.search.coordinator import SearchCoordinator
|
|
12
|
+
from gobby.memory.services.crossref import CrossrefService
|
|
13
|
+
from gobby.storage.database import DatabaseProtocol
|
|
14
|
+
from gobby.storage.memories import LocalMemoryManager, Memory
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchService:
|
|
20
|
+
"""Service for handling memory search and cross-referencing."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
storage: LocalMemoryManager,
|
|
25
|
+
config: MemoryConfig,
|
|
26
|
+
db: DatabaseProtocol,
|
|
27
|
+
):
|
|
28
|
+
self.storage = storage
|
|
29
|
+
self.config = config
|
|
30
|
+
|
|
31
|
+
self._search_coordinator = SearchCoordinator(
|
|
32
|
+
storage=storage,
|
|
33
|
+
config=config,
|
|
34
|
+
db=db,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self._crossref_service = CrossrefService(
|
|
38
|
+
storage=storage,
|
|
39
|
+
config=config,
|
|
40
|
+
search_backend_getter=lambda: self._search_coordinator.search_backend,
|
|
41
|
+
ensure_fitted=self._search_coordinator.ensure_fitted,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def backend(self) -> Any:
|
|
46
|
+
"""Get the underlying search backend."""
|
|
47
|
+
return self._search_coordinator.search_backend
|
|
48
|
+
|
|
49
|
+
def ensure_fitted(self) -> None:
|
|
50
|
+
"""Ensure the search backend is fitted with current memories."""
|
|
51
|
+
self._search_coordinator.ensure_fitted()
|
|
52
|
+
|
|
53
|
+
def mark_refit_needed(self) -> None:
|
|
54
|
+
"""Mark that the search backend needs to be refitted."""
|
|
55
|
+
self._search_coordinator.mark_refit_needed()
|
|
56
|
+
|
|
57
|
+
def reindex(self) -> dict[str, Any]:
|
|
58
|
+
"""Force rebuild of the search index."""
|
|
59
|
+
return self._search_coordinator.reindex()
|
|
60
|
+
|
|
61
|
+
def search(
|
|
62
|
+
self,
|
|
63
|
+
query: str,
|
|
64
|
+
project_id: str | None = None,
|
|
65
|
+
limit: int = 10,
|
|
66
|
+
min_importance: float | None = None,
|
|
67
|
+
search_mode: str | None = None,
|
|
68
|
+
tags_all: list[str] | None = None,
|
|
69
|
+
tags_any: list[str] | None = None,
|
|
70
|
+
tags_none: list[str] | None = None,
|
|
71
|
+
) -> list[Memory]:
|
|
72
|
+
"""Perform search using the configured search backend."""
|
|
73
|
+
return self._search_coordinator.search(
|
|
74
|
+
query=query,
|
|
75
|
+
project_id=project_id,
|
|
76
|
+
limit=limit,
|
|
77
|
+
min_importance=min_importance,
|
|
78
|
+
search_mode=search_mode,
|
|
79
|
+
tags_all=tags_all,
|
|
80
|
+
tags_any=tags_any,
|
|
81
|
+
tags_none=tags_none,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def create_crossrefs(
|
|
85
|
+
self,
|
|
86
|
+
memory: Memory,
|
|
87
|
+
threshold: float | None = None,
|
|
88
|
+
max_links: int | None = None,
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Find and link similar memories."""
|
|
91
|
+
return await self._crossref_service.create_crossrefs(
|
|
92
|
+
memory=memory,
|
|
93
|
+
threshold=threshold,
|
|
94
|
+
max_links=max_links,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def get_related(
|
|
98
|
+
self,
|
|
99
|
+
memory_id: str,
|
|
100
|
+
limit: int = 5,
|
|
101
|
+
min_similarity: float = 0.0,
|
|
102
|
+
) -> list[Memory]:
|
|
103
|
+
"""Get memories linked to this one via cross-references."""
|
|
104
|
+
return await self._crossref_service.get_related(
|
|
105
|
+
memory_id=memory_id,
|
|
106
|
+
limit=limit,
|
|
107
|
+
min_similarity=min_similarity,
|
|
108
|
+
)
|
gobby/memory/extractor.py
CHANGED
|
@@ -153,10 +153,15 @@ class SessionMemoryExtractor:
|
|
|
153
153
|
"""
|
|
154
154
|
session = self.session_manager.get(session_id)
|
|
155
155
|
if not session:
|
|
156
|
+
logger.warning(f"Session not found for memory extraction: {session_id}")
|
|
156
157
|
return None
|
|
157
158
|
|
|
158
|
-
# Get project info
|
|
159
|
+
# Get project info - log for debugging NULL project_id issues
|
|
159
160
|
project_id = session.project_id
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"Memory extraction context: session={session_id}, "
|
|
163
|
+
f"project_id={project_id!r} (type={type(project_id).__name__})"
|
|
164
|
+
)
|
|
160
165
|
project_name = "Unknown Project"
|
|
161
166
|
|
|
162
167
|
if project_id:
|
|
@@ -461,6 +466,15 @@ class SessionMemoryExtractor:
|
|
|
461
466
|
session_id: Source session ID
|
|
462
467
|
project_id: Project ID for the memories
|
|
463
468
|
"""
|
|
469
|
+
# Log project_id for debugging NULL project_id issues
|
|
470
|
+
if project_id is None:
|
|
471
|
+
logger.warning(
|
|
472
|
+
f"Storing memories with NULL project_id for session {session_id}. "
|
|
473
|
+
"This may cause duplicate detection issues."
|
|
474
|
+
)
|
|
475
|
+
else:
|
|
476
|
+
logger.debug(f"Storing {len(candidates)} memories with project_id={project_id}")
|
|
477
|
+
|
|
464
478
|
for candidate in candidates:
|
|
465
479
|
try:
|
|
466
480
|
await self.memory_manager.remember(
|
gobby/memory/manager.py
CHANGED
|
@@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Any
|
|
|
6
6
|
|
|
7
7
|
from gobby.config.persistence import MemoryConfig
|
|
8
8
|
from gobby.memory.backends import get_backend
|
|
9
|
+
from gobby.memory.components.ingestion import IngestionService
|
|
10
|
+
from gobby.memory.components.search import SearchService
|
|
9
11
|
from gobby.memory.context import build_memory_context
|
|
10
|
-
from gobby.memory.ingestion import MultimodalIngestor
|
|
11
12
|
from gobby.memory.protocol import MemoryBackendProtocol
|
|
12
|
-
from gobby.memory.search.coordinator import SearchCoordinator
|
|
13
|
-
from gobby.memory.services.crossref import CrossrefService
|
|
14
13
|
from gobby.storage.database import DatabaseProtocol
|
|
15
14
|
from gobby.storage.memories import LocalMemoryManager, Memory
|
|
16
15
|
|
|
@@ -46,20 +45,13 @@ class MemoryManager:
|
|
|
46
45
|
self.storage = LocalMemoryManager(db)
|
|
47
46
|
|
|
48
47
|
# Initialize extracted components
|
|
49
|
-
self.
|
|
48
|
+
self._search_service = SearchService(
|
|
50
49
|
storage=self.storage,
|
|
51
50
|
config=config,
|
|
52
51
|
db=db,
|
|
53
52
|
)
|
|
54
53
|
|
|
55
|
-
self.
|
|
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(
|
|
54
|
+
self._ingestion_service = IngestionService(
|
|
63
55
|
storage=self.storage,
|
|
64
56
|
backend=self._backend,
|
|
65
57
|
llm_service=llm_service,
|
|
@@ -68,14 +60,13 @@ class MemoryManager:
|
|
|
68
60
|
@property
|
|
69
61
|
def llm_service(self) -> LLMService | None:
|
|
70
62
|
"""Get the LLM service for image description."""
|
|
71
|
-
return self.
|
|
63
|
+
return self._ingestion_service.llm_service
|
|
72
64
|
|
|
73
65
|
@llm_service.setter
|
|
74
66
|
def llm_service(self, service: LLMService | None) -> None:
|
|
75
67
|
"""Set the LLM service for image description."""
|
|
76
68
|
self._llm_service = service
|
|
77
|
-
|
|
78
|
-
self._multimodal_ingestor.llm_service = service
|
|
69
|
+
self._ingestion_service.llm_service = service
|
|
79
70
|
|
|
80
71
|
@property
|
|
81
72
|
def search_backend(self) -> Any:
|
|
@@ -86,15 +77,15 @@ class MemoryManager:
|
|
|
86
77
|
- "tfidf" (default): Zero-dependency TF-IDF search
|
|
87
78
|
- "text": Simple text substring matching
|
|
88
79
|
"""
|
|
89
|
-
return self.
|
|
80
|
+
return self._search_service.backend
|
|
90
81
|
|
|
91
82
|
def _ensure_search_backend_fitted(self) -> None:
|
|
92
83
|
"""Ensure the search backend is fitted with current memories."""
|
|
93
|
-
self.
|
|
84
|
+
self._search_service.ensure_fitted()
|
|
94
85
|
|
|
95
86
|
def mark_search_refit_needed(self) -> None:
|
|
96
87
|
"""Mark that the search backend needs to be refitted."""
|
|
97
|
-
self.
|
|
88
|
+
self._search_service.mark_refit_needed()
|
|
98
89
|
|
|
99
90
|
def reindex_search(self) -> dict[str, Any]:
|
|
100
91
|
"""
|
|
@@ -109,7 +100,7 @@ class MemoryManager:
|
|
|
109
100
|
Returns:
|
|
110
101
|
Dict with index statistics including memory_count, backend_type, etc.
|
|
111
102
|
"""
|
|
112
|
-
return self.
|
|
103
|
+
return self._search_service.reindex()
|
|
113
104
|
|
|
114
105
|
async def remember(
|
|
115
106
|
self,
|
|
@@ -161,7 +152,7 @@ class MemoryManager:
|
|
|
161
152
|
# Auto cross-reference if enabled
|
|
162
153
|
if getattr(self.config, "auto_crossref", False):
|
|
163
154
|
try:
|
|
164
|
-
await self.
|
|
155
|
+
await self._search_service.create_crossrefs(memory)
|
|
165
156
|
except Exception as e:
|
|
166
157
|
# Don't fail the remember if crossref fails
|
|
167
158
|
logger.warning(f"Auto-crossref failed for {memory.id}: {e}")
|
|
@@ -202,7 +193,7 @@ class MemoryManager:
|
|
|
202
193
|
Raises:
|
|
203
194
|
ValueError: If LLM service is not configured or image not found
|
|
204
195
|
"""
|
|
205
|
-
memory = await self.
|
|
196
|
+
memory = await self._ingestion_service.remember_with_image(
|
|
206
197
|
image_path=image_path,
|
|
207
198
|
context=context,
|
|
208
199
|
memory_type=memory_type,
|
|
@@ -249,7 +240,7 @@ class MemoryManager:
|
|
|
249
240
|
Raises:
|
|
250
241
|
ValueError: If LLM service is not configured or screenshot bytes are empty
|
|
251
242
|
"""
|
|
252
|
-
memory = await self.
|
|
243
|
+
memory = await self._ingestion_service.remember_screenshot(
|
|
253
244
|
screenshot_bytes=screenshot_bytes,
|
|
254
245
|
context=context,
|
|
255
246
|
memory_type=memory_type,
|
|
@@ -283,7 +274,7 @@ class MemoryManager:
|
|
|
283
274
|
Returns:
|
|
284
275
|
Number of cross-references created
|
|
285
276
|
"""
|
|
286
|
-
return await self.
|
|
277
|
+
return await self._search_service.create_crossrefs(
|
|
287
278
|
memory=memory,
|
|
288
279
|
threshold=threshold,
|
|
289
280
|
max_links=max_links,
|
|
@@ -306,7 +297,7 @@ class MemoryManager:
|
|
|
306
297
|
Returns:
|
|
307
298
|
List of related Memory objects, sorted by similarity
|
|
308
299
|
"""
|
|
309
|
-
return await self.
|
|
300
|
+
return await self._search_service.get_related(
|
|
310
301
|
memory_id=memory_id,
|
|
311
302
|
limit=limit,
|
|
312
303
|
min_similarity=min_similarity,
|
|
@@ -398,7 +389,7 @@ class MemoryManager:
|
|
|
398
389
|
if use_semantic is not None:
|
|
399
390
|
logger.warning("use_semantic argument is deprecated and ignored")
|
|
400
391
|
|
|
401
|
-
return self.
|
|
392
|
+
return self._search_service.search(
|
|
402
393
|
query=query,
|
|
403
394
|
project_id=project_id,
|
|
404
395
|
limit=limit,
|
gobby/paths.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core path utilities for Gobby package.
|
|
3
|
+
|
|
4
|
+
This module provides stable path resolution utilities that work in both
|
|
5
|
+
development (source) and installed (package) modes without CLI dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
__all__ = ["get_package_root", "get_install_dir"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_package_root() -> Path:
|
|
14
|
+
"""Get the root directory of the gobby package.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Path to src/gobby/ (the package root directory)
|
|
18
|
+
"""
|
|
19
|
+
import gobby
|
|
20
|
+
|
|
21
|
+
return Path(gobby.__file__).parent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_install_dir() -> Path:
|
|
25
|
+
"""Get the gobby install directory.
|
|
26
|
+
|
|
27
|
+
Checks for source directory (development mode) first,
|
|
28
|
+
falls back to package directory. This handles both:
|
|
29
|
+
- Development: src/gobby/install/
|
|
30
|
+
- Installed package: <site-packages>/gobby/install/
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to the install directory
|
|
34
|
+
"""
|
|
35
|
+
import gobby
|
|
36
|
+
|
|
37
|
+
package_install_dir = Path(gobby.__file__).parent / "install"
|
|
38
|
+
|
|
39
|
+
# Try to find source directory (project root) for development mode
|
|
40
|
+
current = Path(gobby.__file__).resolve()
|
|
41
|
+
source_install_dir = None
|
|
42
|
+
|
|
43
|
+
for parent in current.parents:
|
|
44
|
+
potential_source = parent / "src" / "gobby" / "install"
|
|
45
|
+
if potential_source.exists():
|
|
46
|
+
source_install_dir = potential_source
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if source_install_dir and source_install_dir.exists():
|
|
50
|
+
return source_install_dir
|
|
51
|
+
return package_install_dir
|