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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
- claude_mpm/cli/commands/commander.py +174 -4
- claude_mpm/cli/commands/skill_source.py +51 -2
- claude_mpm/cli/commands/skills.py +5 -3
- claude_mpm/cli/parsers/commander_parser.py +43 -10
- claude_mpm/cli/parsers/skill_source_parser.py +4 -0
- claude_mpm/cli/parsers/skills_parser.py +5 -0
- claude_mpm/cli/startup.py +140 -20
- claude_mpm/cli/startup_display.py +2 -1
- claude_mpm/commander/__init__.py +6 -0
- claude_mpm/commander/adapters/__init__.py +32 -3
- claude_mpm/commander/adapters/auggie.py +260 -0
- claude_mpm/commander/adapters/base.py +98 -1
- claude_mpm/commander/adapters/claude_code.py +32 -1
- claude_mpm/commander/adapters/codex.py +237 -0
- claude_mpm/commander/adapters/example_usage.py +310 -0
- claude_mpm/commander/adapters/mpm.py +389 -0
- claude_mpm/commander/adapters/registry.py +204 -0
- claude_mpm/commander/api/app.py +32 -16
- claude_mpm/commander/api/errors.py +21 -0
- claude_mpm/commander/api/routes/messages.py +11 -11
- claude_mpm/commander/api/routes/projects.py +20 -20
- claude_mpm/commander/api/routes/sessions.py +37 -26
- claude_mpm/commander/api/routes/work.py +86 -50
- claude_mpm/commander/api/schemas.py +4 -0
- claude_mpm/commander/chat/cli.py +42 -3
- claude_mpm/commander/config.py +5 -3
- claude_mpm/commander/core/__init__.py +10 -0
- claude_mpm/commander/core/block_manager.py +325 -0
- claude_mpm/commander/core/response_manager.py +323 -0
- claude_mpm/commander/daemon.py +215 -10
- claude_mpm/commander/env_loader.py +59 -0
- claude_mpm/commander/frameworks/base.py +4 -1
- claude_mpm/commander/instance_manager.py +124 -11
- claude_mpm/commander/memory/__init__.py +45 -0
- claude_mpm/commander/memory/compression.py +347 -0
- claude_mpm/commander/memory/embeddings.py +230 -0
- claude_mpm/commander/memory/entities.py +310 -0
- claude_mpm/commander/memory/example_usage.py +290 -0
- claude_mpm/commander/memory/integration.py +325 -0
- claude_mpm/commander/memory/search.py +381 -0
- claude_mpm/commander/memory/store.py +657 -0
- claude_mpm/commander/registry.py +10 -4
- claude_mpm/commander/runtime/monitor.py +32 -2
- claude_mpm/commander/work/executor.py +38 -20
- claude_mpm/commander/workflow/event_handler.py +25 -3
- claude_mpm/config/skill_sources.py +16 -0
- claude_mpm/core/claude_runner.py +152 -0
- claude_mpm/core/config.py +30 -22
- claude_mpm/core/config_constants.py +74 -9
- claude_mpm/core/constants.py +56 -12
- claude_mpm/core/interactive_session.py +5 -4
- claude_mpm/core/logging_utils.py +4 -2
- claude_mpm/core/network_config.py +148 -0
- claude_mpm/core/oneshot_session.py +7 -6
- claude_mpm/core/output_style_manager.py +37 -7
- claude_mpm/core/socketio_pool.py +13 -5
- claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
- claude_mpm/hooks/claude_hooks/event_handlers.py +22 -0
- claude_mpm/hooks/claude_hooks/hook_handler.py +6 -6
- claude_mpm/hooks/claude_hooks/installer.py +43 -2
- claude_mpm/hooks/claude_hooks/memory_integration.py +31 -22
- claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
- claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
- claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
- claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
- claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
- claude_mpm/hooks/session_resume_hook.py +22 -18
- claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
- claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
- claude_mpm/scripts/claude-hook-handler.sh +8 -8
- claude_mpm/services/agents/agent_selection_service.py +2 -2
- claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
- claude_mpm/services/command_deployment_service.py +44 -26
- claude_mpm/services/pm_skills_deployer.py +3 -2
- claude_mpm/services/skills/git_skill_source_manager.py +79 -8
- claude_mpm/services/skills/selective_skill_deployer.py +28 -0
- claude_mpm/services/skills/skill_discovery_service.py +17 -1
- claude_mpm/services/skills_deployer.py +31 -5
- claude_mpm/skills/__init__.py +2 -1
- claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
- claude_mpm/skills/registry.py +295 -90
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/METADATA +5 -3
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/RECORD +103 -71
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/WHEEL +0 -0
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/entry_points.txt +0 -0
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.6.4.dist-info → claude_mpm-5.6.30.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {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)
|