gobby 0.2.5__py3-none-any.whl → 0.2.6__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 (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,477 @@
1
+ """
2
+ Session memory extractor.
3
+
4
+ Automatically extracts meaningful, reusable memories from session transcripts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from gobby.prompts.loader import PromptLoader
16
+ from gobby.workflows.summary_actions import format_turns_for_llm
17
+
18
+ if TYPE_CHECKING:
19
+ from gobby.llm.service import LLMService
20
+ from gobby.memory.manager import MemoryManager
21
+ from gobby.storage.sessions import LocalSessionManager
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Prompt path in the prompts collection
26
+ EXTRACT_PROMPT_PATH = "memory/extract"
27
+
28
+
29
+ @dataclass
30
+ class MemoryCandidate:
31
+ """A candidate memory extracted from a session."""
32
+
33
+ content: str
34
+ memory_type: str # fact, pattern, preference, context
35
+ importance: float
36
+ tags: list[str]
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ """Convert to dictionary."""
40
+ return {
41
+ "content": self.content,
42
+ "memory_type": self.memory_type,
43
+ "importance": self.importance,
44
+ "tags": self.tags,
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class SessionContext:
50
+ """Context extracted from a session for memory extraction."""
51
+
52
+ session_id: str
53
+ project_id: str | None
54
+ project_name: str
55
+ task_refs: str
56
+ files_modified: str
57
+ tool_summary: str
58
+ transcript_summary: str
59
+
60
+
61
+ class SessionMemoryExtractor:
62
+ """Extract meaningful memories from session transcripts.
63
+
64
+ Uses LLM analysis to identify high-value, reusable knowledge from
65
+ session transcripts and stores them as memories.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ memory_manager: MemoryManager,
71
+ session_manager: LocalSessionManager,
72
+ llm_service: LLMService,
73
+ prompt_loader: PromptLoader | None = None,
74
+ transcript_processor: Any | None = None,
75
+ ):
76
+ """Initialize the extractor.
77
+
78
+ Args:
79
+ memory_manager: Manager for storing extracted memories
80
+ session_manager: Manager for session data
81
+ llm_service: LLM service for analysis
82
+ prompt_loader: Optional custom prompt loader
83
+ transcript_processor: Optional transcript processor for parsing
84
+ """
85
+ self.memory_manager = memory_manager
86
+ self.session_manager = session_manager
87
+ self.llm_service = llm_service
88
+ self.prompt_loader = prompt_loader or PromptLoader()
89
+ self.transcript_processor = transcript_processor
90
+
91
+ async def extract(
92
+ self,
93
+ session_id: str,
94
+ min_importance: float = 0.7,
95
+ max_memories: int = 5,
96
+ dry_run: bool = False,
97
+ ) -> list[MemoryCandidate]:
98
+ """Extract memories from a session.
99
+
100
+ Args:
101
+ session_id: The session to extract memories from
102
+ min_importance: Minimum importance threshold (0.0-1.0)
103
+ max_memories: Maximum number of memories to extract
104
+ dry_run: If True, don't store memories, just return candidates
105
+
106
+ Returns:
107
+ List of extracted memory candidates
108
+ """
109
+ # 1. Get session context
110
+ context = await self._get_session_context(session_id)
111
+ if not context:
112
+ logger.warning(f"Could not get context for session {session_id}")
113
+ return []
114
+
115
+ # 2. Load and render prompt
116
+ prompt = self._render_prompt(
117
+ context=context,
118
+ min_importance=min_importance,
119
+ max_memories=max_memories,
120
+ )
121
+
122
+ # 3. LLM analysis
123
+ candidates = await self._analyze_with_llm(prompt)
124
+ if not candidates:
125
+ logger.debug(f"No memory candidates extracted from session {session_id}")
126
+ return []
127
+
128
+ # 4. Quality filter + deduplicate
129
+ filtered = await self._filter_and_dedupe(
130
+ candidates=candidates,
131
+ min_importance=min_importance,
132
+ project_id=context.project_id,
133
+ )
134
+
135
+ # 5. Store (unless dry_run)
136
+ if not dry_run and filtered:
137
+ await self._store_memories(
138
+ candidates=filtered,
139
+ session_id=session_id,
140
+ project_id=context.project_id,
141
+ )
142
+
143
+ return filtered
144
+
145
+ async def _get_session_context(self, session_id: str) -> SessionContext | None:
146
+ """Get context from the session for memory extraction.
147
+
148
+ Args:
149
+ session_id: The session ID
150
+
151
+ Returns:
152
+ SessionContext with extracted information, or None if not available
153
+ """
154
+ session = self.session_manager.get(session_id)
155
+ if not session:
156
+ return None
157
+
158
+ # Get project info
159
+ project_id = session.project_id
160
+ project_name = "Unknown Project"
161
+
162
+ if project_id:
163
+ # Try to get project name from project manager
164
+ try:
165
+ from gobby.storage.projects import LocalProjectManager
166
+
167
+ project_mgr = LocalProjectManager(self.memory_manager.db)
168
+ project = project_mgr.get(project_id)
169
+ if project and project.name:
170
+ project_name = project.name
171
+ except Exception as e:
172
+ logger.debug(f"Could not get project name: {e}")
173
+
174
+ # Get transcript content
175
+ transcript_path = getattr(session, "jsonl_path", None)
176
+ transcript_summary = ""
177
+ task_refs = ""
178
+ files_modified = ""
179
+ tool_summary_parts: list[str] = []
180
+
181
+ if transcript_path and Path(transcript_path).exists():
182
+ turns = self._load_transcript(transcript_path)
183
+
184
+ # Extract turns since last clear (or all if no clear)
185
+ if self.transcript_processor:
186
+ recent_turns = self.transcript_processor.extract_turns_since_clear(
187
+ turns, max_turns=50
188
+ )
189
+ else:
190
+ recent_turns = turns[-50:] if len(turns) > 50 else turns
191
+
192
+ # Format for LLM
193
+ transcript_summary = format_turns_for_llm(recent_turns)
194
+
195
+ # Extract file modifications and tool usage from turns
196
+ files_set: set[str] = set()
197
+ task_set: set[str] = set()
198
+
199
+ for turn in recent_turns:
200
+ message = turn.get("message", {})
201
+ content = message.get("content", [])
202
+
203
+ if isinstance(content, list):
204
+ for block in content:
205
+ if isinstance(block, dict) and block.get("type") == "tool_use":
206
+ tool_name = block.get("name", "")
207
+ tool_input = block.get("input", {})
208
+
209
+ # Track file modifications
210
+ if tool_name in ("Edit", "Write", "NotebookEdit"):
211
+ file_path = tool_input.get("file_path", "")
212
+ if file_path:
213
+ files_set.add(file_path)
214
+
215
+ # Track task references
216
+ if tool_name in ("update_task", "create_task", "close_task"):
217
+ task_id = tool_input.get("task_id", "")
218
+ if task_id:
219
+ task_set.add(task_id)
220
+
221
+ # Track key tool actions
222
+ if tool_name in ("Edit", "Write", "Bash", "Grep", "Glob"):
223
+ tool_summary_parts.append(tool_name)
224
+
225
+ files_modified = ", ".join(sorted(files_set)) if files_set else "None"
226
+ task_refs = ", ".join(sorted(task_set)) if task_set else "None"
227
+
228
+ # Create tool summary (count of each tool type)
229
+ tool_counts: dict[str, int] = {}
230
+ for tool in tool_summary_parts:
231
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
232
+ tool_summary = ", ".join(f"{k}({v})" for k, v in sorted(tool_counts.items()))
233
+
234
+ return SessionContext(
235
+ session_id=session_id,
236
+ project_id=project_id,
237
+ project_name=project_name,
238
+ task_refs=task_refs,
239
+ files_modified=files_modified,
240
+ tool_summary=tool_summary or "None",
241
+ transcript_summary=transcript_summary,
242
+ )
243
+
244
+ def _load_transcript(self, transcript_path: str) -> list[dict[str, Any]]:
245
+ """Load transcript turns from JSONL file.
246
+
247
+ Args:
248
+ transcript_path: Path to the transcript file
249
+
250
+ Returns:
251
+ List of turn dictionaries
252
+ """
253
+ turns: list[dict[str, Any]] = []
254
+ try:
255
+ with open(transcript_path, encoding="utf-8") as f:
256
+ for line in f:
257
+ if line.strip():
258
+ turns.append(json.loads(line))
259
+ except Exception as e:
260
+ logger.warning(f"Failed to load transcript: {e}")
261
+ return turns
262
+
263
+ def _render_prompt(
264
+ self,
265
+ context: SessionContext,
266
+ min_importance: float,
267
+ max_memories: int,
268
+ ) -> str:
269
+ """Render the extraction prompt with context.
270
+
271
+ Args:
272
+ context: Session context
273
+ min_importance: Minimum importance threshold
274
+ max_memories: Maximum memories to extract
275
+
276
+ Returns:
277
+ Rendered prompt string
278
+ """
279
+ return self.prompt_loader.render(
280
+ EXTRACT_PROMPT_PATH,
281
+ {
282
+ "project_name": context.project_name,
283
+ "task_refs": context.task_refs,
284
+ "files": context.files_modified,
285
+ "tool_summary": context.tool_summary,
286
+ "transcript_summary": context.transcript_summary,
287
+ "min_importance": min_importance,
288
+ "max_memories": max_memories,
289
+ },
290
+ )
291
+
292
+ async def _analyze_with_llm(self, prompt: str) -> list[MemoryCandidate]:
293
+ """Call LLM to analyze transcript and extract memories.
294
+
295
+ Args:
296
+ prompt: Rendered prompt for the LLM
297
+
298
+ Returns:
299
+ List of memory candidates extracted from LLM response
300
+ """
301
+ try:
302
+ provider = self.llm_service.get_default_provider()
303
+ response = await provider.generate_text(prompt)
304
+
305
+ # Parse JSON from response
306
+ candidates = self._parse_llm_response(response)
307
+ return candidates
308
+
309
+ except Exception as e:
310
+ logger.error(f"LLM analysis failed: {e}")
311
+ return []
312
+
313
+ def _parse_llm_response(self, response: str) -> list[MemoryCandidate]:
314
+ """Parse LLM response to extract memory candidates.
315
+
316
+ Args:
317
+ response: Raw LLM response text
318
+
319
+ Returns:
320
+ List of memory candidates
321
+ """
322
+ candidates: list[MemoryCandidate] = []
323
+
324
+ # Try to find JSON array in response
325
+ try:
326
+ # Look for JSON array markers
327
+ start_idx = response.find("[")
328
+ end_idx = response.rfind("]")
329
+
330
+ if start_idx == -1 or end_idx == -1:
331
+ logger.warning("No JSON array found in LLM response")
332
+ return []
333
+
334
+ json_str = response[start_idx : end_idx + 1]
335
+ data = json.loads(json_str)
336
+
337
+ if not isinstance(data, list):
338
+ logger.warning("LLM response is not a list")
339
+ return []
340
+
341
+ for item in data:
342
+ if not isinstance(item, dict):
343
+ continue
344
+
345
+ content = item.get("content", "").strip()
346
+ if not content:
347
+ continue
348
+
349
+ memory_type = item.get("memory_type", "fact")
350
+ if memory_type not in ("fact", "pattern", "preference", "context"):
351
+ memory_type = "fact"
352
+
353
+ raw_importance = item.get("importance", 0.7)
354
+ try:
355
+ importance = float(raw_importance)
356
+ except (ValueError, TypeError) as e:
357
+ logger.warning(
358
+ f"Invalid importance value '{raw_importance}' in memory item "
359
+ f"(content: {content[:50]}...): {e}. Using default 0.7"
360
+ )
361
+ importance = 0.7
362
+ importance = max(0.0, min(1.0, importance))
363
+
364
+ tags = item.get("tags", [])
365
+ if not isinstance(tags, list):
366
+ tags = []
367
+ tags = [str(t) for t in tags]
368
+
369
+ candidates.append(
370
+ MemoryCandidate(
371
+ content=content,
372
+ memory_type=memory_type,
373
+ importance=importance,
374
+ tags=tags,
375
+ )
376
+ )
377
+
378
+ except json.JSONDecodeError as e:
379
+ logger.warning(f"Failed to parse LLM response as JSON: {e}")
380
+ except Exception as e:
381
+ logger.warning(f"Error parsing LLM response: {e}")
382
+
383
+ return candidates
384
+
385
+ async def _filter_and_dedupe(
386
+ self,
387
+ candidates: list[MemoryCandidate],
388
+ min_importance: float,
389
+ project_id: str | None,
390
+ ) -> list[MemoryCandidate]:
391
+ """Filter candidates by importance and deduplicate against existing memories.
392
+
393
+ Args:
394
+ candidates: Raw candidates from LLM
395
+ min_importance: Minimum importance threshold
396
+ project_id: Project ID for deduplication
397
+
398
+ Returns:
399
+ Filtered and deduplicated candidates
400
+ """
401
+ filtered: list[MemoryCandidate] = []
402
+
403
+ for candidate in candidates:
404
+ # Skip low importance
405
+ if candidate.importance < min_importance:
406
+ continue
407
+
408
+ # Check for duplicates in existing memories
409
+ if self.memory_manager.content_exists(candidate.content, project_id):
410
+ logger.debug(f"Skipping duplicate memory: {candidate.content[:50]}...")
411
+ continue
412
+
413
+ # Check for near-duplicates in this batch
414
+ is_duplicate = False
415
+ for existing in filtered:
416
+ if self._is_similar(candidate.content, existing.content):
417
+ is_duplicate = True
418
+ break
419
+
420
+ if not is_duplicate:
421
+ filtered.append(candidate)
422
+
423
+ return filtered
424
+
425
+ def _is_similar(self, content1: str, content2: str, threshold: float = 0.8) -> bool:
426
+ """Check if two content strings are similar enough to be considered duplicates.
427
+
428
+ Uses a simple word overlap heuristic.
429
+
430
+ Args:
431
+ content1: First content string
432
+ content2: Second content string
433
+ threshold: Similarity threshold (0.0-1.0)
434
+
435
+ Returns:
436
+ True if contents are similar
437
+ """
438
+ words1 = set(content1.lower().split())
439
+ words2 = set(content2.lower().split())
440
+
441
+ if not words1 or not words2:
442
+ return False
443
+
444
+ # Jaccard similarity
445
+ intersection = len(words1 & words2)
446
+ union = len(words1 | words2)
447
+
448
+ similarity = intersection / union if union > 0 else 0
449
+ return similarity >= threshold
450
+
451
+ async def _store_memories(
452
+ self,
453
+ candidates: list[MemoryCandidate],
454
+ session_id: str,
455
+ project_id: str | None,
456
+ ) -> None:
457
+ """Store extracted memories.
458
+
459
+ Args:
460
+ candidates: Memory candidates to store
461
+ session_id: Source session ID
462
+ project_id: Project ID for the memories
463
+ """
464
+ for candidate in candidates:
465
+ try:
466
+ await self.memory_manager.remember(
467
+ content=candidate.content,
468
+ memory_type=candidate.memory_type,
469
+ importance=candidate.importance,
470
+ project_id=project_id,
471
+ source_type="session",
472
+ source_session_id=session_id,
473
+ tags=candidate.tags,
474
+ )
475
+ logger.debug(f"Stored memory: {candidate.content[:50]}...")
476
+ except Exception as e:
477
+ logger.warning(f"Failed to store memory: {e}")
gobby/memory/manager.py CHANGED
@@ -178,8 +178,17 @@ class MemoryManager:
178
178
  source_session_id: Origin session
179
179
  tags: Optional tags
180
180
  """
181
- # Future: Duplicate detection via embeddings or fuzzy match?
182
- # For now, rely on storage layer (which uses content-hash ID for dedup)
181
+ # Check for existing memory with same content to avoid duplicates.
182
+ # The storage layer also checks via content-hash ID, but this provides
183
+ # an additional safeguard against race conditions and project_id mismatches.
184
+ normalized_content = content.strip()
185
+ if self.storage.content_exists(normalized_content, project_id):
186
+ # Return existing memory by computing the same content-derived ID
187
+ # that the storage layer uses, avoiding reliance on search ordering
188
+ existing_memory = self.storage.get_memory_by_content(normalized_content, project_id)
189
+ if existing_memory:
190
+ logger.debug(f"Memory already exists: {existing_memory.id}")
191
+ return existing_memory
183
192
 
184
193
  memory = self.storage.create_memory(
185
194
  content=content,
@@ -0,0 +1,63 @@
1
+ ---
2
+ description: Compact handoff summary prompt for cumulative compression
3
+ required_variables:
4
+ - transcript_summary
5
+ - last_messages
6
+ - git_status
7
+ - file_changes
8
+ optional_variables:
9
+ - previous_summary
10
+ - todo_list
11
+ ---
12
+ You are creating a session continuation summary after a compaction event.
13
+
14
+ ## Context from Earlier in This Session (if any):
15
+ {{ previous_summary }}
16
+
17
+ If there is previous context above, focus your summary on what happened AFTER
18
+ that point. Compress the historical context into a brief "Session History" section.
19
+ If no previous context, this is the first segment - summarize the full session.
20
+
21
+ ## Current Transcript:
22
+ {{ transcript_summary }}
23
+
24
+ ## Last Messages:
25
+ {{ last_messages }}
26
+
27
+ ## Git Status:
28
+ {{ git_status }}
29
+
30
+ ## Files Changed:
31
+ {{ file_changes }}
32
+
33
+ {% if todo_list %}
34
+ {{ todo_list }}
35
+ {% endif %}
36
+
37
+ ---
38
+
39
+ Create a continuation summary optimized for resuming work after compaction.
40
+ Use these sections:
41
+
42
+ ### Current Focus
43
+ [What is being actively worked on RIGHT NOW - be specific and detailed.
44
+ This is the most important section.]
45
+
46
+ ### This Segment's Progress
47
+ [Bullet points of what was accomplished in this segment]
48
+
49
+ ### Session History
50
+ [1-2 sentences summarizing the overall session journey. Include if there was
51
+ previous context, otherwise skip this section.]
52
+
53
+ ### Technical State
54
+ - Key files modified: [list files]
55
+ - Git status: [uncommitted changes summary]
56
+ - Any blockers or pending items
57
+
58
+ ### Next Steps
59
+ [Numbered list of concrete actions to take when resuming]
60
+
61
+ IMPORTANT: Prioritize recency. "Current Focus" and "This Segment's Progress"
62
+ should be detailed and specific. Historical context should be compressed.
63
+ Use only ASCII-safe characters.
@@ -0,0 +1,57 @@
1
+ ---
2
+ description: Session end handoff summary prompt
3
+ required_variables:
4
+ - transcript_summary
5
+ - last_messages
6
+ - git_status
7
+ - file_changes
8
+ optional_variables:
9
+ - todo_list
10
+ ---
11
+ Analyze this Claude Code session transcript and create a comprehensive session summary.
12
+
13
+ ## Transcript (last 50 turns):
14
+ {{ transcript_summary }}
15
+
16
+ ## Last Messages:
17
+ {{ last_messages }}
18
+
19
+ ## Git Status:
20
+ {{ git_status }}
21
+
22
+ ## Files Changed:
23
+ {{ file_changes }}
24
+
25
+ Create a markdown summary with the following sections (do NOT include a top-level '# Session Summary' header):
26
+
27
+ ## Overview
28
+ [1-2 paragraph summary of what was accomplished in this session]
29
+
30
+ ## Key Decisions
31
+ [List of important technical or architectural decisions made, with bullet points]
32
+
33
+ ## Important Lessons Learned
34
+ [Technical insights, gotchas, or patterns discovered, with bullet points]
35
+
36
+ ## Substantive Interrupts
37
+ [Times when the user changed direction significantly - NOT simple "continue" or "resume" prompts]
38
+
39
+ ## Research & Epiphanies
40
+ [Key discoveries from research or debugging that should be remembered, with bullet points]
41
+
42
+ ## Files Changed
43
+ {{ file_changes }}
44
+ [Add specific details about WHY each file was changed and WHAT the changes accomplish.]
45
+
46
+ ## Git Status
47
+ ```
48
+ {{ git_status }}
49
+ ```
50
+
51
+ {{ todo_list }}
52
+
53
+ ## Next Steps
54
+ [Concrete, numbered suggestions for what to do when resuming work. Be specific and actionable.]
55
+
56
+ Focus on actionable insights and context that would be valuable when resuming work later.
57
+ Use only ASCII-safe characters - avoid Unicode em-dashes, smart quotes, or special characters.
@@ -0,0 +1,61 @@
1
+ ---
2
+ description: Extract reusable memories from session transcripts
3
+ required_variables:
4
+ - transcript_summary
5
+ - project_name
6
+ optional_variables:
7
+ - task_refs
8
+ - files
9
+ - tool_summary
10
+ - min_importance
11
+ - max_memories
12
+ defaults:
13
+ min_importance: 0.7
14
+ max_memories: 5
15
+ ---
16
+ Analyze this coding session and extract ONLY high-value, reusable memories.
17
+
18
+ ## Session Context
19
+ - Project: {{ project_name }}
20
+ {% if task_refs %}- Tasks worked: {{ task_refs }}{% endif %}
21
+ {% if files %}- Files modified: {{ files }}{% endif %}
22
+ {% if tool_summary %}- Key tool actions: {{ tool_summary }}{% endif %}
23
+
24
+ ## Session Transcript
25
+ {{ transcript_summary }}
26
+
27
+ ## Extract memories that are:
28
+ - **FACTS**: Project architecture, technology choices, API patterns, file locations
29
+ - **PATTERNS**: Code conventions, testing approaches, file organization, naming conventions
30
+ - **PREFERENCES**: User-stated preferences about style, approach, or tools
31
+ - **CONTEXT**: Important background that helps future work on this project
32
+
33
+ ## DO NOT extract:
34
+ - Temporary debugging information
35
+ - Session-specific state that won't apply later
36
+ - Obvious/generic programming knowledge
37
+ - Information already documented in the codebase
38
+ - Duplicate information from previous memories
39
+
40
+ ## Output Format
41
+ Return a JSON array of memories. Each memory should be:
42
+ - Self-contained and understandable without session context
43
+ - Specific to this project (not generic programming advice)
44
+ - Actionable or informative for future sessions
45
+
46
+ ```json
47
+ [
48
+ {
49
+ "content": "The reusable knowledge (1-3 sentences, specific and actionable)",
50
+ "memory_type": "fact | pattern | preference | context",
51
+ "importance": 0.7,
52
+ "tags": ["relevant", "tags"]
53
+ }
54
+ ]
55
+ ```
56
+
57
+ Guidelines:
58
+ - Only include memories with importance >= {{ min_importance | default(0.7) }}
59
+ - Maximum {{ max_memories | default(5) }} memories
60
+ - If nothing worth remembering, return an empty array: []
61
+ - importance scale: 0.7 = useful, 0.8 = valuable, 0.9 = critical, 1.0 = essential