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.
Files changed (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {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: Filter by owning session.
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=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: The session ID claiming ownership.
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 != 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, session_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._search_coordinator = SearchCoordinator(
48
+ self._search_service = SearchService(
50
49
  storage=self.storage,
51
50
  config=config,
52
51
  db=db,
53
52
  )
54
53
 
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(
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._llm_service
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
- # Keep multimodal ingestor in sync
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._search_coordinator.search_backend
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._search_coordinator.ensure_fitted()
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._search_coordinator.mark_refit_needed()
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._search_coordinator.reindex()
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._crossref_service.create_crossrefs(memory)
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._multimodal_ingestor.remember_with_image(
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._multimodal_ingestor.remember_screenshot(
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._crossref_service.create_crossrefs(
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._crossref_service.get_related(
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._search_coordinator.search(
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