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.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
-
#
|
|
182
|
-
#
|
|
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
|