claude-mpm 5.6.4__py3-none-any.whl → 5.6.30__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 (103) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
  3. claude_mpm/cli/commands/commander.py +174 -4
  4. claude_mpm/cli/commands/skill_source.py +51 -2
  5. claude_mpm/cli/commands/skills.py +5 -3
  6. claude_mpm/cli/parsers/commander_parser.py +43 -10
  7. claude_mpm/cli/parsers/skill_source_parser.py +4 -0
  8. claude_mpm/cli/parsers/skills_parser.py +5 -0
  9. claude_mpm/cli/startup.py +140 -20
  10. claude_mpm/cli/startup_display.py +2 -1
  11. claude_mpm/commander/__init__.py +6 -0
  12. claude_mpm/commander/adapters/__init__.py +32 -3
  13. claude_mpm/commander/adapters/auggie.py +260 -0
  14. claude_mpm/commander/adapters/base.py +98 -1
  15. claude_mpm/commander/adapters/claude_code.py +32 -1
  16. claude_mpm/commander/adapters/codex.py +237 -0
  17. claude_mpm/commander/adapters/example_usage.py +310 -0
  18. claude_mpm/commander/adapters/mpm.py +389 -0
  19. claude_mpm/commander/adapters/registry.py +204 -0
  20. claude_mpm/commander/api/app.py +32 -16
  21. claude_mpm/commander/api/errors.py +21 -0
  22. claude_mpm/commander/api/routes/messages.py +11 -11
  23. claude_mpm/commander/api/routes/projects.py +20 -20
  24. claude_mpm/commander/api/routes/sessions.py +37 -26
  25. claude_mpm/commander/api/routes/work.py +86 -50
  26. claude_mpm/commander/api/schemas.py +4 -0
  27. claude_mpm/commander/chat/cli.py +42 -3
  28. claude_mpm/commander/config.py +5 -3
  29. claude_mpm/commander/core/__init__.py +10 -0
  30. claude_mpm/commander/core/block_manager.py +325 -0
  31. claude_mpm/commander/core/response_manager.py +323 -0
  32. claude_mpm/commander/daemon.py +215 -10
  33. claude_mpm/commander/env_loader.py +59 -0
  34. claude_mpm/commander/frameworks/base.py +4 -1
  35. claude_mpm/commander/instance_manager.py +124 -11
  36. claude_mpm/commander/memory/__init__.py +45 -0
  37. claude_mpm/commander/memory/compression.py +347 -0
  38. claude_mpm/commander/memory/embeddings.py +230 -0
  39. claude_mpm/commander/memory/entities.py +310 -0
  40. claude_mpm/commander/memory/example_usage.py +290 -0
  41. claude_mpm/commander/memory/integration.py +325 -0
  42. claude_mpm/commander/memory/search.py +381 -0
  43. claude_mpm/commander/memory/store.py +657 -0
  44. claude_mpm/commander/registry.py +10 -4
  45. claude_mpm/commander/runtime/monitor.py +32 -2
  46. claude_mpm/commander/work/executor.py +38 -20
  47. claude_mpm/commander/workflow/event_handler.py +25 -3
  48. claude_mpm/config/skill_sources.py +16 -0
  49. claude_mpm/core/claude_runner.py +152 -0
  50. claude_mpm/core/config.py +30 -22
  51. claude_mpm/core/config_constants.py +74 -9
  52. claude_mpm/core/constants.py +56 -12
  53. claude_mpm/core/interactive_session.py +5 -4
  54. claude_mpm/core/logging_utils.py +4 -2
  55. claude_mpm/core/network_config.py +148 -0
  56. claude_mpm/core/oneshot_session.py +7 -6
  57. claude_mpm/core/output_style_manager.py +37 -7
  58. claude_mpm/core/socketio_pool.py +13 -5
  59. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  60. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
  61. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  62. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  63. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  64. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  65. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  66. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  67. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
  68. claude_mpm/hooks/claude_hooks/event_handlers.py +22 -0
  69. claude_mpm/hooks/claude_hooks/hook_handler.py +6 -6
  70. claude_mpm/hooks/claude_hooks/installer.py +43 -2
  71. claude_mpm/hooks/claude_hooks/memory_integration.py +31 -22
  72. claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
  73. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  74. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  75. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  76. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  77. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  78. claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
  79. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
  80. claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
  81. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
  82. claude_mpm/hooks/session_resume_hook.py +22 -18
  83. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  84. claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
  85. claude_mpm/scripts/claude-hook-handler.sh +8 -8
  86. claude_mpm/services/agents/agent_selection_service.py +2 -2
  87. claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
  88. claude_mpm/services/command_deployment_service.py +44 -26
  89. claude_mpm/services/pm_skills_deployer.py +3 -2
  90. claude_mpm/services/skills/git_skill_source_manager.py +79 -8
  91. claude_mpm/services/skills/selective_skill_deployer.py +28 -0
  92. claude_mpm/services/skills/skill_discovery_service.py +17 -1
  93. claude_mpm/services/skills_deployer.py +31 -5
  94. claude_mpm/skills/__init__.py +2 -1
  95. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  96. claude_mpm/skills/registry.py +295 -90
  97. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/METADATA +5 -3
  98. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/RECORD +103 -71
  99. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/WHEEL +0 -0
  100. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/entry_points.txt +0 -0
  101. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/licenses/LICENSE +0 -0
  102. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  103. {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/top_level.txt +0 -0
@@ -19,6 +19,7 @@ class InstanceInfo:
19
19
  pane_target: Tmux pane target (e.g., "%1")
20
20
  git_branch: Current git branch if project is a git repo
21
21
  git_status: Git status summary if project is a git repo
22
+ connected: Whether instance has an active adapter connection
22
23
 
23
24
  Example:
24
25
  >>> info = InstanceInfo(
@@ -28,7 +29,8 @@ class InstanceInfo:
28
29
  ... tmux_session="mpm-commander",
29
30
  ... pane_target="%1",
30
31
  ... git_branch="main",
31
- ... git_status="clean"
32
+ ... git_status="clean",
33
+ ... connected=True
32
34
  ... )
33
35
  """
34
36
 
@@ -39,6 +41,7 @@ class InstanceInfo:
39
41
  pane_target: str
40
42
  git_branch: Optional[str] = None
41
43
  git_status: Optional[str] = None
44
+ connected: bool = False
42
45
 
43
46
 
44
47
  class BaseFramework(ABC):
@@ -167,6 +167,20 @@ class InstanceManager:
167
167
  startup_cmd = framework_obj.get_startup_command(project_path)
168
168
  self.orchestrator.send_keys(pane_target, startup_cmd)
169
169
 
170
+ # Create communication adapter for the instance (only for Claude Code for now)
171
+ # Do this BEFORE creating InstanceInfo so we can set connected=True
172
+ has_adapter = False
173
+ if framework == "cc":
174
+ runtime_adapter = ClaudeCodeAdapter()
175
+ comm_adapter = ClaudeCodeCommunicationAdapter(
176
+ orchestrator=self.orchestrator,
177
+ pane_target=pane_target,
178
+ runtime_adapter=runtime_adapter,
179
+ )
180
+ self._adapters[name] = comm_adapter
181
+ has_adapter = True
182
+ logger.debug(f"Created communication adapter for instance '{name}'")
183
+
170
184
  # Create instance info
171
185
  instance = InstanceInfo(
172
186
  name=name,
@@ -176,22 +190,12 @@ class InstanceManager:
176
190
  pane_target=pane_target,
177
191
  git_branch=git_branch,
178
192
  git_status=git_status,
193
+ connected=has_adapter,
179
194
  )
180
195
 
181
196
  # Track instance
182
197
  self._instances[name] = instance
183
198
 
184
- # Create communication adapter for the instance (only for Claude Code for now)
185
- if framework == "cc":
186
- runtime_adapter = ClaudeCodeAdapter()
187
- comm_adapter = ClaudeCodeCommunicationAdapter(
188
- orchestrator=self.orchestrator,
189
- pane_target=pane_target,
190
- runtime_adapter=runtime_adapter,
191
- )
192
- self._adapters[name] = comm_adapter
193
- logger.debug(f"Created communication adapter for instance '{name}'")
194
-
195
199
  logger.info(
196
200
  f"Started instance '{name}' with framework '{framework}' at {project_path}"
197
201
  )
@@ -226,6 +230,7 @@ class InstanceManager:
226
230
  # Remove adapter if exists
227
231
  if name in self._adapters:
228
232
  del self._adapters[name]
233
+ instance.connected = False
229
234
  logger.debug(f"Removed adapter for instance '{name}'")
230
235
 
231
236
  # Remove from tracking
@@ -335,3 +340,111 @@ class InstanceManager:
335
340
  ... print(chunk, end='')
336
341
  """
337
342
  return self._adapters.get(name)
343
+
344
+ async def rename_instance(self, old_name: str, new_name: str) -> bool:
345
+ """Rename an instance.
346
+
347
+ Args:
348
+ old_name: Current instance name
349
+ new_name: New instance name
350
+
351
+ Returns:
352
+ True if renamed successfully
353
+
354
+ Raises:
355
+ InstanceNotFoundError: If old_name doesn't exist
356
+ InstanceAlreadyExistsError: If new_name already exists
357
+
358
+ Example:
359
+ >>> manager = InstanceManager(orchestrator)
360
+ >>> await manager.rename_instance("myapp", "myapp-v2")
361
+ True
362
+ """
363
+ # Validate old_name exists
364
+ if old_name not in self._instances:
365
+ raise InstanceNotFoundError(old_name)
366
+
367
+ # Validate new_name doesn't exist
368
+ if new_name in self._instances:
369
+ raise InstanceAlreadyExistsError(new_name)
370
+
371
+ # Get instance and update name
372
+ instance = self._instances[old_name]
373
+ instance.name = new_name
374
+
375
+ # Update _instances dict (remove old key, add new)
376
+ del self._instances[old_name]
377
+ self._instances[new_name] = instance
378
+
379
+ # Update _adapters dict if exists
380
+ if old_name in self._adapters:
381
+ adapter = self._adapters[old_name]
382
+ del self._adapters[old_name]
383
+ self._adapters[new_name] = adapter
384
+ logger.debug(f"Moved adapter from '{old_name}' to '{new_name}'")
385
+
386
+ logger.info(f"Renamed instance from '{old_name}' to '{new_name}'")
387
+
388
+ return True
389
+
390
+ async def close_instance(self, name: str) -> bool:
391
+ """Close and remove an instance.
392
+
393
+ Alias for stop_instance that provides clearer semantics for closing.
394
+
395
+ Args:
396
+ name: Instance name to close
397
+
398
+ Returns:
399
+ True if closed successfully
400
+
401
+ Raises:
402
+ InstanceNotFoundError: If instance not found
403
+
404
+ Example:
405
+ >>> manager = InstanceManager(orchestrator)
406
+ >>> await manager.close_instance("myapp")
407
+ True
408
+ """
409
+ return await self.stop_instance(name)
410
+
411
+ async def disconnect_instance(self, name: str) -> bool:
412
+ """Disconnect from an instance without closing it.
413
+
414
+ The instance keeps running but we stop communication.
415
+ Removes the adapter while keeping the instance tracked.
416
+
417
+ Args:
418
+ name: Instance name to disconnect from
419
+
420
+ Returns:
421
+ True if disconnected successfully
422
+
423
+ Raises:
424
+ InstanceNotFoundError: If instance not found
425
+
426
+ Example:
427
+ >>> manager = InstanceManager(orchestrator)
428
+ >>> await manager.disconnect_instance("myapp")
429
+ True
430
+ >>> # Instance still running, but no adapter connection
431
+ >>> adapter = manager.get_adapter("myapp")
432
+ >>> print(adapter)
433
+ None
434
+ """
435
+ # Validate instance exists
436
+ if name not in self._instances:
437
+ raise InstanceNotFoundError(name)
438
+
439
+ instance = self._instances[name]
440
+
441
+ # Remove adapter if exists (but keep instance)
442
+ if name in self._adapters:
443
+ # Could add cleanup here if adapter has resources to close
444
+ del self._adapters[name]
445
+ instance.connected = False
446
+ logger.info(f"Disconnected from instance '{name}' (instance still running)")
447
+ else:
448
+ logger.debug(f"No adapter to disconnect for instance '{name}'")
449
+
450
+ return True
@@ -0,0 +1,45 @@
1
+ """Conversation memory system for Commander.
2
+
3
+ This module provides semantic search, storage, and context compression
4
+ for all Claude Code instance conversations.
5
+
6
+ Key Components:
7
+ - ConversationStore: CRUD operations for conversations
8
+ - EmbeddingService: Generate vector embeddings
9
+ - SemanticSearch: Query conversations semantically
10
+ - ContextCompressor: Summarize conversations for context
11
+ - EntityExtractor: Extract files, functions, errors
12
+
13
+ Example:
14
+ >>> from claude_mpm.commander.memory import (
15
+ ... ConversationStore,
16
+ ... EmbeddingService,
17
+ ... SemanticSearch,
18
+ ... ContextCompressor,
19
+ ... )
20
+ >>> store = ConversationStore()
21
+ >>> embeddings = EmbeddingService()
22
+ >>> search = SemanticSearch(store, embeddings)
23
+ >>> results = await search.search("how did we fix the login bug?")
24
+ """
25
+
26
+ from .compression import ContextCompressor
27
+ from .embeddings import EmbeddingService
28
+ from .entities import Entity, EntityExtractor, EntityType
29
+ from .integration import MemoryIntegration
30
+ from .search import SearchResult, SemanticSearch
31
+ from .store import Conversation, ConversationMessage, ConversationStore
32
+
33
+ __all__ = [
34
+ "ContextCompressor",
35
+ "Conversation",
36
+ "ConversationMessage",
37
+ "ConversationStore",
38
+ "EmbeddingService",
39
+ "Entity",
40
+ "EntityExtractor",
41
+ "EntityType",
42
+ "MemoryIntegration",
43
+ "SearchResult",
44
+ "SemanticSearch",
45
+ ]
@@ -0,0 +1,347 @@
1
+ """Context compression and conversation summarization.
2
+
3
+ Compresses long conversations into concise summaries for efficient context
4
+ loading when resuming sessions or searching past work.
5
+ """
6
+
7
+ import logging
8
+ from typing import List, Optional
9
+
10
+ from ..llm.openrouter_client import OpenRouterClient
11
+ from .store import Conversation, ConversationMessage
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ContextCompressor:
17
+ """Compress conversations into summaries for context loading.
18
+
19
+ Uses cheap LLM (mistral-small) to generate summaries of conversations
20
+ and compress multiple conversations into context strings.
21
+
22
+ Attributes:
23
+ client: OpenRouterClient for LLM requests
24
+ summary_threshold: Minimum messages to trigger summarization
25
+ max_context_tokens: Maximum tokens for compressed context
26
+
27
+ Example:
28
+ >>> compressor = ContextCompressor(client)
29
+ >>> summary = await compressor.summarize(messages)
30
+ >>> context = await compressor.compress_for_context(
31
+ ... conversations,
32
+ ... max_tokens=4000
33
+ ... )
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ client: OpenRouterClient,
39
+ summary_threshold: int = 10,
40
+ max_context_tokens: int = 4000,
41
+ ):
42
+ """Initialize context compressor.
43
+
44
+ Args:
45
+ client: OpenRouterClient for LLM requests
46
+ summary_threshold: Minimum messages to summarize
47
+ max_context_tokens: Maximum tokens for context string
48
+ """
49
+ self.client = client
50
+ self.summary_threshold = summary_threshold
51
+ self.max_context_tokens = max_context_tokens
52
+
53
+ logger.info(
54
+ "ContextCompressor initialized (threshold: %d msgs, max_tokens: %d)",
55
+ summary_threshold,
56
+ max_context_tokens,
57
+ )
58
+
59
+ async def summarize(
60
+ self,
61
+ messages: List[ConversationMessage],
62
+ focus: Optional[str] = None,
63
+ ) -> str:
64
+ """Generate summary of conversation messages.
65
+
66
+ Args:
67
+ messages: List of messages to summarize
68
+ focus: Optional focus area (e.g., "bug fixes", "API changes")
69
+
70
+ Returns:
71
+ Concise summary (2-4 sentences)
72
+
73
+ Example:
74
+ >>> summary = await compressor.summarize(messages)
75
+ >>> print(summary)
76
+ "Fixed login authentication bug in src/auth.py by updating token validation..."
77
+ """
78
+ if len(messages) < 2:
79
+ # Too short to summarize
80
+ return messages[0].content if messages else ""
81
+
82
+ # Build conversation text
83
+ conversation_text = self._format_messages(messages)
84
+
85
+ # Build summarization prompt
86
+ if focus:
87
+ prompt = f"""Summarize the following conversation, focusing on: {focus}
88
+
89
+ Conversation:
90
+ {conversation_text}
91
+
92
+ Provide a concise summary (2-4 sentences) that captures:
93
+ 1. What was the main task or problem
94
+ 2. What actions were taken
95
+ 3. What was the outcome or current status
96
+ 4. Any important files, functions, or errors mentioned
97
+
98
+ Summary:"""
99
+ else:
100
+ prompt = f"""Summarize the following conversation in 2-4 sentences.
101
+
102
+ Conversation:
103
+ {conversation_text}
104
+
105
+ Focus on:
106
+ 1. What was the main task or problem
107
+ 2. What actions were taken
108
+ 3. What was the outcome or current status
109
+
110
+ Summary:"""
111
+
112
+ messages_for_llm = [{"role": "user", "content": prompt}]
113
+
114
+ system = (
115
+ "You are a technical summarization assistant. "
116
+ "Provide clear, concise summaries of development conversations. "
117
+ "Focus on actionable information and key outcomes."
118
+ )
119
+
120
+ summary = await self.client.chat(messages_for_llm, system=system)
121
+ logger.debug(
122
+ "Generated summary (%d chars) from %d messages", len(summary), len(messages)
123
+ )
124
+
125
+ return summary.strip()
126
+
127
+ async def compress_for_context(
128
+ self,
129
+ conversations: List[Conversation],
130
+ max_tokens: Optional[int] = None,
131
+ prioritize_recent: bool = True,
132
+ ) -> str:
133
+ """Compress multiple conversations into context string.
134
+
135
+ Prioritizes recent conversations and uses summaries for older ones
136
+ to fit within token budget.
137
+
138
+ Args:
139
+ conversations: List of conversations to compress
140
+ max_tokens: Maximum tokens (default: self.max_context_tokens)
141
+ prioritize_recent: Whether to prioritize recent conversations
142
+
143
+ Returns:
144
+ Compressed context string ready for LLM input
145
+
146
+ Example:
147
+ >>> context = await compressor.compress_for_context(
148
+ ... conversations,
149
+ ... max_tokens=4000
150
+ ... )
151
+ >>> print(f"Context: {len(context)} chars")
152
+ """
153
+ if max_tokens is None:
154
+ max_tokens = self.max_context_tokens
155
+
156
+ # Sort by recency if prioritizing
157
+ if prioritize_recent:
158
+ conversations = sorted(
159
+ conversations, key=lambda c: c.updated_at, reverse=True
160
+ )
161
+
162
+ # Build context incrementally
163
+ context_parts = []
164
+ current_tokens = 0
165
+
166
+ for conv in conversations:
167
+ # Use summary if available, else generate one
168
+ if conv.summary:
169
+ summary_text = conv.summary
170
+ elif len(conv.messages) >= self.summary_threshold:
171
+ # Generate summary on-the-fly
172
+ summary_text = await self.summarize(conv.messages)
173
+ else:
174
+ # Use full conversation for short ones
175
+ summary_text = conv.get_full_text()
176
+
177
+ # Format conversation section
178
+ section = self._format_conversation_section(conv, summary_text)
179
+ section_tokens = len(section) // 4 # Rough approximation
180
+
181
+ # Check if adding this would exceed budget
182
+ if current_tokens + section_tokens > max_tokens:
183
+ # Try to fit summary only
184
+ short_summary = summary_text.split(". ")[0] + "."
185
+ short_section = self._format_conversation_section(conv, short_summary)
186
+ short_tokens = len(short_section) // 4
187
+
188
+ if current_tokens + short_tokens <= max_tokens:
189
+ context_parts.append(short_section)
190
+ current_tokens += short_tokens
191
+ else:
192
+ # Can't fit any more, stop
193
+ break
194
+ else:
195
+ context_parts.append(section)
196
+ current_tokens += section_tokens
197
+
198
+ context = "\n\n---\n\n".join(context_parts)
199
+
200
+ logger.info(
201
+ "Compressed %d conversations into context (%d chars, ~%d tokens)",
202
+ len(context_parts),
203
+ len(context),
204
+ current_tokens,
205
+ )
206
+
207
+ return context
208
+
209
+ def needs_summarization(self, messages: List[ConversationMessage]) -> bool:
210
+ """Check if conversation needs summarization.
211
+
212
+ Args:
213
+ messages: List of messages to check
214
+
215
+ Returns:
216
+ True if message count exceeds threshold
217
+
218
+ Example:
219
+ >>> if compressor.needs_summarization(messages):
220
+ ... summary = await compressor.summarize(messages)
221
+ """
222
+ return len(messages) >= self.summary_threshold
223
+
224
+ def _format_messages(
225
+ self,
226
+ messages: List[ConversationMessage],
227
+ max_messages: Optional[int] = None,
228
+ ) -> str:
229
+ """Format messages as text for summarization.
230
+
231
+ Args:
232
+ messages: Messages to format
233
+ max_messages: Maximum messages to include
234
+
235
+ Returns:
236
+ Formatted conversation text
237
+ """
238
+ if max_messages:
239
+ messages = messages[:max_messages]
240
+
241
+ lines = []
242
+ for msg in messages:
243
+ # Format: ROLE: content
244
+ lines.append(f"{msg.role.upper()}: {msg.content}")
245
+
246
+ return "\n\n".join(lines)
247
+
248
+ def _format_conversation_section(
249
+ self, conversation: Conversation, summary: str
250
+ ) -> str:
251
+ """Format conversation section for context string.
252
+
253
+ Args:
254
+ conversation: Conversation to format
255
+ summary: Summary or full text
256
+
257
+ Returns:
258
+ Formatted section with metadata
259
+ """
260
+ # Format timestamp
261
+ timestamp = conversation.updated_at.strftime("%Y-%m-%d %H:%M")
262
+
263
+ # Build section
264
+ return f"""## Conversation: {conversation.id}
265
+ **Project:** {conversation.project_id}
266
+ **Instance:** {conversation.instance_name}
267
+ **Updated:** {timestamp}
268
+ **Messages:** {conversation.message_count}
269
+
270
+ {summary}"""
271
+
272
+ async def auto_summarize_conversation(
273
+ self, conversation: Conversation
274
+ ) -> Optional[str]:
275
+ """Automatically summarize conversation if needed.
276
+
277
+ Checks if conversation needs summarization and generates one if so.
278
+ Updates the conversation's summary field but does NOT save to store.
279
+
280
+ Args:
281
+ conversation: Conversation to summarize
282
+
283
+ Returns:
284
+ Summary if generated, None if not needed
285
+
286
+ Example:
287
+ >>> summary = await compressor.auto_summarize_conversation(conv)
288
+ >>> if summary:
289
+ ... conv.summary = summary
290
+ ... await store.save(conv)
291
+ """
292
+ if not self.needs_summarization(conversation.messages):
293
+ logger.debug(
294
+ "Conversation %s too short to summarize (%d messages)",
295
+ conversation.id,
296
+ len(conversation.messages),
297
+ )
298
+ return None
299
+
300
+ if conversation.summary:
301
+ logger.debug("Conversation %s already has summary", conversation.id)
302
+ return conversation.summary
303
+
304
+ # Generate summary
305
+ summary = await self.summarize(conversation.messages)
306
+ logger.info("Auto-generated summary for conversation %s", conversation.id)
307
+
308
+ return summary
309
+
310
+ async def update_summary_if_stale(
311
+ self,
312
+ conversation: Conversation,
313
+ message_threshold: int = 5,
314
+ ) -> Optional[str]:
315
+ """Update summary if conversation has grown significantly.
316
+
317
+ Args:
318
+ conversation: Conversation to check
319
+ message_threshold: New messages required to trigger update
320
+
321
+ Returns:
322
+ Updated summary if regenerated, None otherwise
323
+
324
+ Example:
325
+ >>> updated = await compressor.update_summary_if_stale(conv)
326
+ >>> if updated:
327
+ ... conv.summary = updated
328
+ ... await store.save(conv)
329
+ """
330
+ if not conversation.summary:
331
+ # No existing summary, generate one
332
+ return await self.auto_summarize_conversation(conversation)
333
+
334
+ # Check if conversation has grown significantly
335
+ # (Simple heuristic: if more than threshold messages since last summarization)
336
+ # In practice, you'd track when summary was generated
337
+ if len(conversation.messages) < self.summary_threshold + message_threshold:
338
+ return None
339
+
340
+ # Regenerate summary
341
+ logger.info(
342
+ "Regenerating stale summary for conversation %s (%d messages)",
343
+ conversation.id,
344
+ len(conversation.messages),
345
+ )
346
+
347
+ return await self.summarize(conversation.messages)