kollabor 0.4.9__py3-none-any.whl → 0.4.15__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.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
core/llm/conversation_manager.py
CHANGED
|
@@ -6,11 +6,13 @@ and message threading for LLM interactions.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
|
-
from datetime import datetime
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Dict, List, Optional
|
|
12
12
|
from uuid import uuid4
|
|
13
13
|
|
|
14
|
+
from ..utils.session_naming import generate_session_name, generate_branch_name
|
|
15
|
+
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
|
|
@@ -23,16 +25,20 @@ class ConversationManager:
|
|
|
23
25
|
|
|
24
26
|
def __init__(self, config, conversation_logger=None):
|
|
25
27
|
"""Initialize conversation manager.
|
|
26
|
-
|
|
28
|
+
|
|
27
29
|
Args:
|
|
28
30
|
config: Configuration manager
|
|
29
31
|
conversation_logger: Optional conversation logger instance
|
|
30
32
|
"""
|
|
31
33
|
self.config = config
|
|
32
34
|
self.conversation_logger = conversation_logger
|
|
33
|
-
|
|
34
|
-
# Conversation state
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
# Conversation state - use memorable session names
|
|
37
|
+
# Use logger's session ID if available for consistency across logging systems
|
|
38
|
+
if conversation_logger:
|
|
39
|
+
self.current_session_id = conversation_logger.session_id
|
|
40
|
+
else:
|
|
41
|
+
self.current_session_id = generate_session_name()
|
|
36
42
|
self.messages = []
|
|
37
43
|
self.message_index = {} # uuid -> message lookup
|
|
38
44
|
self.context_window = []
|
|
@@ -42,11 +48,14 @@ class ConversationManager:
|
|
|
42
48
|
self.max_context_tokens = config.get("core.llm.max_context_tokens", 4000)
|
|
43
49
|
self.save_conversations = config.get("core.llm.save_conversations", True)
|
|
44
50
|
|
|
45
|
-
# Conversation storage
|
|
46
|
-
from ..utils.config_utils import
|
|
47
|
-
|
|
48
|
-
self.conversations_dir = config_dir / "conversations"
|
|
51
|
+
# Conversation storage - use centralized project-specific directory
|
|
52
|
+
from ..utils.config_utils import get_conversations_dir
|
|
53
|
+
self.conversations_dir = get_conversations_dir()
|
|
49
54
|
self.conversations_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
# Snapshots directory for JSON exports (separate from JSONL logs)
|
|
57
|
+
self.snapshots_dir = self.conversations_dir / "snapshots"
|
|
58
|
+
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
50
59
|
|
|
51
60
|
# Current conversation metadata
|
|
52
61
|
self.current_parent_uuid = None # Track parent UUID for message threading
|
|
@@ -113,8 +122,8 @@ class ConversationManager:
|
|
|
113
122
|
metadata=metadata
|
|
114
123
|
)
|
|
115
124
|
|
|
116
|
-
# Auto-save if configured
|
|
117
|
-
if self.save_conversations and len(self.messages) %
|
|
125
|
+
# Auto-save if configured (save every message)
|
|
126
|
+
if self.save_conversations and len(self.messages) % 1 == 0:
|
|
118
127
|
self.save_conversation()
|
|
119
128
|
|
|
120
129
|
logger.debug(f"Added {role} message: {message_uuid}")
|
|
@@ -245,9 +254,19 @@ class ConversationManager:
|
|
|
245
254
|
"""Calculate conversation duration."""
|
|
246
255
|
if not self.messages:
|
|
247
256
|
return "0m"
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
257
|
+
|
|
258
|
+
def parse_timestamp(ts: str) -> datetime:
|
|
259
|
+
"""Parse timestamp, normalizing to naive UTC."""
|
|
260
|
+
# Handle Z suffix
|
|
261
|
+
ts = ts.replace('Z', '+00:00')
|
|
262
|
+
dt = datetime.fromisoformat(ts)
|
|
263
|
+
# Convert to naive UTC for consistent comparison
|
|
264
|
+
if dt.tzinfo is not None:
|
|
265
|
+
dt = dt.replace(tzinfo=None)
|
|
266
|
+
return dt
|
|
267
|
+
|
|
268
|
+
start = parse_timestamp(self.messages[0]["timestamp"])
|
|
269
|
+
end = parse_timestamp(self.messages[-1]["timestamp"])
|
|
251
270
|
duration = end - start
|
|
252
271
|
|
|
253
272
|
minutes = duration.total_seconds() / 60
|
|
@@ -267,18 +286,20 @@ class ConversationManager:
|
|
|
267
286
|
|
|
268
287
|
def save_conversation(self, filename: Optional[str] = None) -> Path:
|
|
269
288
|
"""Save current conversation to file.
|
|
270
|
-
|
|
289
|
+
|
|
271
290
|
Args:
|
|
272
291
|
filename: Optional custom filename
|
|
273
|
-
|
|
292
|
+
|
|
274
293
|
Returns:
|
|
275
294
|
Path to saved conversation file
|
|
276
295
|
"""
|
|
277
296
|
if not filename:
|
|
278
|
-
timestamp
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
297
|
+
# Use short timestamp since session_id already contains date
|
|
298
|
+
timestamp = datetime.now().strftime("%H%M%S")
|
|
299
|
+
filename = f"{self.current_session_id}_snapshot.json"
|
|
300
|
+
|
|
301
|
+
# Save snapshots to dedicated snapshots subdirectory
|
|
302
|
+
filepath = self.snapshots_dir / filename
|
|
282
303
|
|
|
283
304
|
conversation_data = {
|
|
284
305
|
"metadata": self.conversation_metadata,
|
|
@@ -304,10 +325,19 @@ class ConversationManager:
|
|
|
304
325
|
try:
|
|
305
326
|
with open(filepath, 'r') as f:
|
|
306
327
|
data = json.load(f)
|
|
307
|
-
|
|
328
|
+
|
|
308
329
|
self.messages = data.get("messages", [])
|
|
309
|
-
|
|
310
|
-
|
|
330
|
+
|
|
331
|
+
# Restore metadata with defaults for required fields
|
|
332
|
+
loaded_metadata = data.get("metadata", {})
|
|
333
|
+
self.conversation_metadata = {
|
|
334
|
+
"started_at": loaded_metadata.get("started_at", datetime.now().isoformat()),
|
|
335
|
+
"message_count": loaded_metadata.get("message_count", len(self.messages)),
|
|
336
|
+
"turn_count": loaded_metadata.get("turn_count", sum(1 for m in self.messages if m.get("role") == "user")),
|
|
337
|
+
"topics": loaded_metadata.get("topics", []),
|
|
338
|
+
"model_used": loaded_metadata.get("model_used"),
|
|
339
|
+
}
|
|
340
|
+
|
|
311
341
|
# Rebuild message index
|
|
312
342
|
self.message_index = {m["uuid"]: m for m in self.messages}
|
|
313
343
|
|
|
@@ -321,21 +351,367 @@ class ConversationManager:
|
|
|
321
351
|
logger.error(f"Failed to load conversation: {e}")
|
|
322
352
|
return False
|
|
323
353
|
|
|
354
|
+
def save_session(self, session_id: str) -> bool:
|
|
355
|
+
"""Save current session state.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
session_id: Session identifier
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
True if saved successfully
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
# Use standard naming: {session_id}.jsonl
|
|
365
|
+
filename = f"{session_id}.jsonl"
|
|
366
|
+
filepath = self.conversations_dir / filename
|
|
367
|
+
|
|
368
|
+
session_data = {
|
|
369
|
+
"session_id": session_id,
|
|
370
|
+
"metadata": self.conversation_metadata,
|
|
371
|
+
"summary": self.get_conversation_summary(),
|
|
372
|
+
"messages": self.messages,
|
|
373
|
+
"message_index": self.message_index,
|
|
374
|
+
"context_window": self.context_window,
|
|
375
|
+
"current_parent_uuid": self.current_parent_uuid,
|
|
376
|
+
"saved_at": datetime.now().isoformat()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
with open(filepath, 'w') as f:
|
|
380
|
+
# Write as single-line JSON for JSONL compatibility
|
|
381
|
+
f.write(json.dumps(session_data) + '\n')
|
|
382
|
+
|
|
383
|
+
logger.info(f"Saved session {session_id} to: {filepath}")
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.error(f"Failed to save session: {e}")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def load_session(self, session_id: str) -> bool:
|
|
391
|
+
"""Load session from storage.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
session_id: Session identifier (with or without 'session_' prefix)
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
True if loaded successfully
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
# Standard format: {session_id}.jsonl
|
|
401
|
+
session_file = self.conversations_dir / f"{session_id}.jsonl"
|
|
402
|
+
|
|
403
|
+
if not session_file.exists():
|
|
404
|
+
logger.error(f"Session file not found: {session_file}")
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
data = self._load_from_jsonl(session_file)
|
|
408
|
+
if not data:
|
|
409
|
+
logger.error(f"Session data empty for: {session_id}")
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
# Restore session state
|
|
413
|
+
self.current_session_id = session_id
|
|
414
|
+
self.messages = data.get("messages", [])
|
|
415
|
+
self.message_index = data.get("message_index", {})
|
|
416
|
+
self.context_window = data.get("context_window", [])
|
|
417
|
+
self.current_parent_uuid = data.get("current_parent_uuid")
|
|
418
|
+
|
|
419
|
+
# Restore metadata with defaults for required fields
|
|
420
|
+
loaded_metadata = data.get("metadata", {})
|
|
421
|
+
self.conversation_metadata = {
|
|
422
|
+
"started_at": loaded_metadata.get("started_at", datetime.now().isoformat()),
|
|
423
|
+
"message_count": loaded_metadata.get("message_count", len(self.messages)),
|
|
424
|
+
"turn_count": loaded_metadata.get("turn_count", sum(1 for m in self.messages if m.get("role") == "user")),
|
|
425
|
+
"topics": loaded_metadata.get("topics", []),
|
|
426
|
+
"model_used": loaded_metadata.get("model_used"),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Rebuild message index if missing
|
|
430
|
+
if not self.message_index:
|
|
431
|
+
self.message_index = {m["uuid"]: m for m in self.messages}
|
|
432
|
+
|
|
433
|
+
# Update context window
|
|
434
|
+
self._update_context_window()
|
|
435
|
+
|
|
436
|
+
logger.info(f"Loaded session: {session_id} from: {session_file}")
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.error(f"Failed to load session: {e}")
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
def get_available_sessions(self) -> List[Dict[str, Any]]:
|
|
444
|
+
"""Get list of available sessions.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
List of session metadata
|
|
448
|
+
"""
|
|
449
|
+
sessions = []
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
# Find both old format (session_*) and new format (YYMMDDHHMM-*) files
|
|
453
|
+
all_files = (
|
|
454
|
+
list(self.conversations_dir.glob("session_*.json")) +
|
|
455
|
+
list(self.conversations_dir.glob("session_*.jsonl")) +
|
|
456
|
+
list(self.conversations_dir.glob("[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*.jsonl")) # New format
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
for file_path in all_files:
|
|
460
|
+
try:
|
|
461
|
+
if file_path.suffix == ".jsonl":
|
|
462
|
+
session_info = self._parse_jsonl_metadata(file_path)
|
|
463
|
+
else:
|
|
464
|
+
with open(file_path, 'r') as f:
|
|
465
|
+
data = json.load(f)
|
|
466
|
+
metadata = data.get("metadata", {})
|
|
467
|
+
summary = data.get("summary", {})
|
|
468
|
+
session_info = {
|
|
469
|
+
"session_id": data.get("session_id", file_path.stem.replace("session_", "")),
|
|
470
|
+
"file_path": str(file_path),
|
|
471
|
+
"start_time": metadata.get("started_at"),
|
|
472
|
+
"message_count": len(data.get("messages", [])),
|
|
473
|
+
"turn_count": metadata.get("turn_count", 0),
|
|
474
|
+
"topics": metadata.get("topics", []),
|
|
475
|
+
"working_directory": metadata.get("working_directory", "unknown"),
|
|
476
|
+
"git_branch": metadata.get("git_branch", "unknown"),
|
|
477
|
+
"last_activity": data.get("saved_at"),
|
|
478
|
+
"size_bytes": file_path.stat().st_size,
|
|
479
|
+
"duration": summary.get("duration", "0m"),
|
|
480
|
+
"preview_messages": data.get("messages", [])[:3]
|
|
481
|
+
}
|
|
482
|
+
sessions.append(session_info)
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning(f"Failed to parse session file {file_path}: {e}")
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
# Sort by last activity (newest first)
|
|
489
|
+
sessions.sort(key=lambda x: x.get("last_activity", ""), reverse=True)
|
|
490
|
+
|
|
491
|
+
except Exception as e:
|
|
492
|
+
logger.error(f"Failed to scan sessions directory: {e}")
|
|
493
|
+
|
|
494
|
+
return sessions
|
|
495
|
+
|
|
496
|
+
def validate_session(self, session_id: str) -> Dict[str, Any]:
|
|
497
|
+
"""Validate session for resume compatibility.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
session_id: Session identifier (with or without 'session_' prefix)
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Validation result with issues
|
|
504
|
+
"""
|
|
505
|
+
validation_result = {
|
|
506
|
+
"valid": True,
|
|
507
|
+
"issues": [],
|
|
508
|
+
"warnings": [],
|
|
509
|
+
"compatibility_score": 1.0
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
# Standard format: {session_id}.jsonl
|
|
514
|
+
session_file = self.conversations_dir / f"{session_id}.jsonl"
|
|
515
|
+
|
|
516
|
+
if not session_file.exists():
|
|
517
|
+
validation_result["valid"] = False
|
|
518
|
+
validation_result["issues"].append("Session file not found")
|
|
519
|
+
return validation_result
|
|
520
|
+
|
|
521
|
+
parsed = self._parse_jsonl_metadata(session_file)
|
|
522
|
+
if not parsed:
|
|
523
|
+
validation_result["valid"] = False
|
|
524
|
+
validation_result["issues"].append("Could not parse session log")
|
|
525
|
+
return validation_result
|
|
526
|
+
|
|
527
|
+
metadata = {
|
|
528
|
+
"working_directory": parsed.get("working_directory"),
|
|
529
|
+
"git_branch": parsed.get("git_branch"),
|
|
530
|
+
"topics": parsed.get("topics", []),
|
|
531
|
+
"turn_count": parsed.get("turn_count", 0),
|
|
532
|
+
"started_at": parsed.get("start_time"),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
# Check working directory
|
|
536
|
+
old_directory = metadata.get("working_directory")
|
|
537
|
+
if old_directory and not Path(old_directory).exists():
|
|
538
|
+
validation_result["warnings"].append(f"Original working directory no longer exists: {old_directory}")
|
|
539
|
+
validation_result["compatibility_score"] -= 0.2
|
|
540
|
+
|
|
541
|
+
# Check for missing files (simplified - just check metadata)
|
|
542
|
+
missing_files = []
|
|
543
|
+
if missing_files:
|
|
544
|
+
validation_result["warnings"].append(f"Some referenced files are missing: {missing_files[:3]}")
|
|
545
|
+
validation_result["compatibility_score"] -= 0.1
|
|
546
|
+
|
|
547
|
+
# Ensure compatibility score is within bounds
|
|
548
|
+
validation_result["compatibility_score"] = max(0.0, validation_result["compatibility_score"])
|
|
549
|
+
|
|
550
|
+
except Exception as e:
|
|
551
|
+
validation_result["valid"] = False
|
|
552
|
+
validation_result["issues"].append(f"Validation error: {str(e)}")
|
|
553
|
+
|
|
554
|
+
return validation_result
|
|
555
|
+
|
|
556
|
+
def _parse_jsonl_metadata(self, file_path: Path) -> Optional[Dict[str, Any]]:
|
|
557
|
+
"""Parse minimal metadata from a JSONL session file."""
|
|
558
|
+
try:
|
|
559
|
+
session_info = {
|
|
560
|
+
"session_id": file_path.stem.replace("session_", ""),
|
|
561
|
+
"file_path": str(file_path),
|
|
562
|
+
"start_time": None,
|
|
563
|
+
"end_time": None,
|
|
564
|
+
"message_count": 0,
|
|
565
|
+
"turn_count": 0,
|
|
566
|
+
"topics": [],
|
|
567
|
+
"working_directory": "unknown",
|
|
568
|
+
"git_branch": "unknown",
|
|
569
|
+
"last_activity": None,
|
|
570
|
+
"size_bytes": file_path.stat().st_size,
|
|
571
|
+
"duration": None,
|
|
572
|
+
"preview_messages": []
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
with open(file_path, "r") as f:
|
|
576
|
+
lines = f.readlines()
|
|
577
|
+
|
|
578
|
+
user_messages = 0
|
|
579
|
+
for line in lines:
|
|
580
|
+
try:
|
|
581
|
+
data = json.loads(line.strip())
|
|
582
|
+
except json.JSONDecodeError:
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
msg_type = data.get("type")
|
|
586
|
+
if msg_type == "conversation_metadata":
|
|
587
|
+
session_info["start_time"] = data.get("startTime")
|
|
588
|
+
session_info["working_directory"] = data.get("cwd", "unknown")
|
|
589
|
+
session_info["git_branch"] = data.get("gitBranch", "unknown")
|
|
590
|
+
elif msg_type == "conversation_end":
|
|
591
|
+
session_info["end_time"] = data.get("endTime")
|
|
592
|
+
summary = data.get("summary", {})
|
|
593
|
+
session_info["topics"] = summary.get("themes", [])
|
|
594
|
+
elif msg_type == "user":
|
|
595
|
+
user_messages += 1
|
|
596
|
+
session_info["message_count"] += 1
|
|
597
|
+
if len(session_info["preview_messages"]) < 3:
|
|
598
|
+
content = data.get("message", {}).get("content", "")
|
|
599
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
600
|
+
session_info["preview_messages"].append(
|
|
601
|
+
{"role": "user", "content": preview, "timestamp": data.get("timestamp")}
|
|
602
|
+
)
|
|
603
|
+
elif msg_type == "assistant":
|
|
604
|
+
session_info["message_count"] += 1
|
|
605
|
+
if len(session_info["preview_messages"]) < 3:
|
|
606
|
+
content = data.get("message", {}).get("content", "")
|
|
607
|
+
preview = content
|
|
608
|
+
if isinstance(content, list) and content:
|
|
609
|
+
preview = content[0].get("text", "")
|
|
610
|
+
preview = preview[:100] + "..." if len(preview) > 100 else preview
|
|
611
|
+
session_info["preview_messages"].append(
|
|
612
|
+
{"role": "assistant", "content": preview, "timestamp": data.get("timestamp")}
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Best-effort duration
|
|
616
|
+
if session_info["start_time"] and session_info["end_time"]:
|
|
617
|
+
try:
|
|
618
|
+
start = datetime.fromisoformat(session_info["start_time"].replace("Z", "+00:00"))
|
|
619
|
+
end = datetime.fromisoformat(session_info["end_time"].replace("Z", "+00:00"))
|
|
620
|
+
session_info["duration"] = f"{int((end - start).total_seconds() / 60)}m"
|
|
621
|
+
except Exception:
|
|
622
|
+
session_info["duration"] = "unknown"
|
|
623
|
+
|
|
624
|
+
session_info["turn_count"] = user_messages
|
|
625
|
+
session_info["last_activity"] = session_info["end_time"] or session_info["start_time"]
|
|
626
|
+
return session_info
|
|
627
|
+
except Exception as e:
|
|
628
|
+
logger.warning(f"Failed to parse JSONL session file {file_path}: {e}")
|
|
629
|
+
return None
|
|
630
|
+
|
|
631
|
+
def _load_from_jsonl(self, session_file: Path) -> Dict[str, Any]:
|
|
632
|
+
"""Load session data from JSONL session file."""
|
|
633
|
+
messages = []
|
|
634
|
+
metadata = {
|
|
635
|
+
"started_at": None,
|
|
636
|
+
"working_directory": "unknown",
|
|
637
|
+
"git_branch": "unknown",
|
|
638
|
+
"turn_count": 0,
|
|
639
|
+
"topics": []
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
with open(session_file, "r") as f:
|
|
644
|
+
for line in f:
|
|
645
|
+
try:
|
|
646
|
+
data = json.loads(line.strip())
|
|
647
|
+
except json.JSONDecodeError:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
# Handle save_session format (complete session object)
|
|
651
|
+
if "messages" in data and isinstance(data["messages"], list):
|
|
652
|
+
return {
|
|
653
|
+
"messages": data["messages"],
|
|
654
|
+
"metadata": data.get("metadata", metadata),
|
|
655
|
+
"message_index": data.get("message_index", {}),
|
|
656
|
+
"context_window": data.get("context_window", []),
|
|
657
|
+
"current_parent_uuid": data.get("current_parent_uuid"),
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# Handle conversation logger streaming format
|
|
661
|
+
msg_type = data.get("type")
|
|
662
|
+
if msg_type == "conversation_metadata":
|
|
663
|
+
metadata["started_at"] = data.get("startTime")
|
|
664
|
+
metadata["working_directory"] = data.get("cwd", "unknown")
|
|
665
|
+
metadata["git_branch"] = data.get("gitBranch", "unknown")
|
|
666
|
+
elif msg_type == "conversation_end":
|
|
667
|
+
summary = data.get("summary", {})
|
|
668
|
+
metadata["topics"] = summary.get("themes", [])
|
|
669
|
+
elif msg_type in ("user", "assistant"):
|
|
670
|
+
content = data.get("message", {}).get("content", "")
|
|
671
|
+
if isinstance(content, list) and content:
|
|
672
|
+
content = content[0].get("text", "")
|
|
673
|
+
msg_uuid = data.get("uuid") or str(uuid4())
|
|
674
|
+
messages.append(
|
|
675
|
+
{
|
|
676
|
+
"uuid": msg_uuid,
|
|
677
|
+
"role": data.get("message", {}).get("role", msg_type),
|
|
678
|
+
"content": content,
|
|
679
|
+
"timestamp": data.get("timestamp"),
|
|
680
|
+
"parent_uuid": None,
|
|
681
|
+
"metadata": {},
|
|
682
|
+
"session_id": session_file.stem.replace("session_", "")
|
|
683
|
+
}
|
|
684
|
+
)
|
|
685
|
+
if msg_type == "user":
|
|
686
|
+
metadata["turn_count"] = metadata.get("turn_count", 0) + 1
|
|
687
|
+
|
|
688
|
+
# Build summary-like shape to keep interface stable
|
|
689
|
+
return {
|
|
690
|
+
"messages": messages,
|
|
691
|
+
"metadata": metadata,
|
|
692
|
+
"message_index": {m["uuid"]: m for m in messages},
|
|
693
|
+
"context_window": messages[-self.max_history :],
|
|
694
|
+
"current_parent_uuid": None,
|
|
695
|
+
}
|
|
696
|
+
except Exception as e:
|
|
697
|
+
logger.error(f"Failed to load JSONL session {session_file}: {e}")
|
|
698
|
+
return {}
|
|
699
|
+
|
|
324
700
|
def clear_conversation(self):
|
|
325
701
|
"""Clear current conversation and start fresh."""
|
|
326
702
|
# Save current conversation if it has messages
|
|
327
703
|
if self.messages and self.save_conversations:
|
|
328
704
|
self.save_conversation()
|
|
329
705
|
|
|
330
|
-
# Reset state
|
|
331
|
-
self.current_session_id =
|
|
706
|
+
# Reset state with new memorable session name
|
|
707
|
+
self.current_session_id = generate_session_name()
|
|
332
708
|
self.messages = []
|
|
333
709
|
self.message_index = {}
|
|
334
710
|
self.context_window = []
|
|
335
|
-
|
|
711
|
+
|
|
336
712
|
# Reset metadata
|
|
337
713
|
self.current_parent_uuid = None # Track parent UUID for message threading
|
|
338
|
-
|
|
714
|
+
|
|
339
715
|
self.conversation_metadata = {
|
|
340
716
|
"started_at": datetime.now().isoformat(),
|
|
341
717
|
"message_count": 0,
|
|
@@ -343,9 +719,159 @@ class ConversationManager:
|
|
|
343
719
|
"topics": [],
|
|
344
720
|
"model_used": None
|
|
345
721
|
}
|
|
346
|
-
|
|
722
|
+
|
|
347
723
|
logger.info(f"Cleared conversation, new session: {self.current_session_id}")
|
|
348
|
-
|
|
724
|
+
|
|
725
|
+
def branch_session(self, source_session_id: str, branch_point_index: int) -> Dict[str, Any]:
|
|
726
|
+
"""Create a new session branching from a specific message in an existing session.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
source_session_id: Session ID to branch from
|
|
730
|
+
branch_point_index: Index of the message to branch from (0-based, inclusive)
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Dict with success status, new session_id, and message count
|
|
734
|
+
"""
|
|
735
|
+
try:
|
|
736
|
+
# Load the source session
|
|
737
|
+
if not self.load_session(source_session_id):
|
|
738
|
+
return {
|
|
739
|
+
"success": False,
|
|
740
|
+
"error": f"Failed to load source session: {source_session_id}"
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
# Validate branch point
|
|
744
|
+
if branch_point_index < 0 or branch_point_index >= len(self.messages):
|
|
745
|
+
return {
|
|
746
|
+
"success": False,
|
|
747
|
+
"error": f"Invalid branch point: {branch_point_index}. Session has {len(self.messages)} messages."
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
# Get messages up to and including the branch point
|
|
751
|
+
branched_messages = self.messages[:branch_point_index + 1]
|
|
752
|
+
|
|
753
|
+
# Create new session ID with memorable branch name
|
|
754
|
+
new_session_id = generate_branch_name()
|
|
755
|
+
|
|
756
|
+
# Update message UUIDs and session IDs for the branch
|
|
757
|
+
new_messages = []
|
|
758
|
+
uuid_map = {} # Map old UUIDs to new UUIDs
|
|
759
|
+
|
|
760
|
+
for msg in branched_messages:
|
|
761
|
+
new_uuid = str(uuid4())
|
|
762
|
+
uuid_map[msg["uuid"]] = new_uuid
|
|
763
|
+
|
|
764
|
+
new_msg = {
|
|
765
|
+
"uuid": new_uuid,
|
|
766
|
+
"role": msg["role"],
|
|
767
|
+
"content": msg["content"],
|
|
768
|
+
"timestamp": msg["timestamp"],
|
|
769
|
+
"parent_uuid": uuid_map.get(msg.get("parent_uuid")),
|
|
770
|
+
"metadata": {
|
|
771
|
+
**msg.get("metadata", {}),
|
|
772
|
+
"branched_from": source_session_id,
|
|
773
|
+
"original_uuid": msg["uuid"]
|
|
774
|
+
},
|
|
775
|
+
"session_id": new_session_id
|
|
776
|
+
}
|
|
777
|
+
new_messages.append(new_msg)
|
|
778
|
+
|
|
779
|
+
# Set up the new session state
|
|
780
|
+
self.current_session_id = new_session_id
|
|
781
|
+
self.messages = new_messages
|
|
782
|
+
self.message_index = {m["uuid"]: m for m in new_messages}
|
|
783
|
+
self._update_context_window()
|
|
784
|
+
|
|
785
|
+
# Update parent UUID for next message
|
|
786
|
+
if new_messages:
|
|
787
|
+
self.current_parent_uuid = new_messages[-1]["uuid"]
|
|
788
|
+
|
|
789
|
+
# Update metadata
|
|
790
|
+
self.conversation_metadata = {
|
|
791
|
+
"started_at": datetime.now().isoformat(),
|
|
792
|
+
"branched_from": source_session_id,
|
|
793
|
+
"branch_point": branch_point_index,
|
|
794
|
+
"message_count": len(new_messages),
|
|
795
|
+
"turn_count": sum(1 for m in new_messages if m["role"] == "user"),
|
|
796
|
+
"topics": [],
|
|
797
|
+
"model_used": None
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# Save the branched session
|
|
801
|
+
self.save_session(new_session_id)
|
|
802
|
+
|
|
803
|
+
logger.info(f"Created branch session {new_session_id} from {source_session_id} at message {branch_point_index}")
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
"success": True,
|
|
807
|
+
"session_id": new_session_id,
|
|
808
|
+
"message_count": len(new_messages),
|
|
809
|
+
"branch_point": branch_point_index,
|
|
810
|
+
"source_session": source_session_id
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
except Exception as e:
|
|
814
|
+
logger.error(f"Failed to branch session: {e}")
|
|
815
|
+
return {
|
|
816
|
+
"success": False,
|
|
817
|
+
"error": str(e)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
def get_session_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
|
821
|
+
"""Get all messages from a session for display/selection.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
session_id: Session ID to get messages from
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
List of messages with index and preview
|
|
828
|
+
"""
|
|
829
|
+
try:
|
|
830
|
+
# Load the session temporarily
|
|
831
|
+
original_state = {
|
|
832
|
+
"session_id": self.current_session_id,
|
|
833
|
+
"messages": self.messages.copy(),
|
|
834
|
+
"message_index": self.message_index.copy(),
|
|
835
|
+
"context_window": self.context_window.copy(),
|
|
836
|
+
"current_parent_uuid": self.current_parent_uuid,
|
|
837
|
+
"metadata": self.conversation_metadata.copy()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if not self.load_session(session_id):
|
|
841
|
+
return []
|
|
842
|
+
|
|
843
|
+
# Extract message previews
|
|
844
|
+
message_list = []
|
|
845
|
+
for i, msg in enumerate(self.messages):
|
|
846
|
+
content = msg.get("content", "")
|
|
847
|
+
# Get first line, truncate if needed
|
|
848
|
+
first_line = content.split('\n')[0][:60]
|
|
849
|
+
if len(content.split('\n')[0]) > 60:
|
|
850
|
+
first_line += "..."
|
|
851
|
+
|
|
852
|
+
message_list.append({
|
|
853
|
+
"index": i,
|
|
854
|
+
"uuid": msg["uuid"],
|
|
855
|
+
"role": msg["role"],
|
|
856
|
+
"preview": first_line,
|
|
857
|
+
"timestamp": msg.get("timestamp", ""),
|
|
858
|
+
"full_content": content[:200] # First 200 chars for hover/detail
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
# Restore original state
|
|
862
|
+
self.current_session_id = original_state["session_id"]
|
|
863
|
+
self.messages = original_state["messages"]
|
|
864
|
+
self.message_index = original_state["message_index"]
|
|
865
|
+
self.context_window = original_state["context_window"]
|
|
866
|
+
self.current_parent_uuid = original_state["current_parent_uuid"]
|
|
867
|
+
self.conversation_metadata = original_state["metadata"]
|
|
868
|
+
|
|
869
|
+
return message_list
|
|
870
|
+
|
|
871
|
+
except Exception as e:
|
|
872
|
+
logger.error(f"Failed to get session messages: {e}")
|
|
873
|
+
return []
|
|
874
|
+
|
|
349
875
|
def export_for_training(self) -> List[Dict[str, str]]:
|
|
350
876
|
"""Export conversation in format suitable for model training.
|
|
351
877
|
|
|
@@ -411,4 +937,4 @@ class ConversationManager:
|
|
|
411
937
|
depth = len(self.get_message_thread(message["uuid"]))
|
|
412
938
|
max_depth = max(max_depth, depth)
|
|
413
939
|
|
|
414
|
-
return max_depth
|
|
940
|
+
return max_depth
|