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.
Files changed (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
@@ -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
- self.current_session_id = str(uuid4())
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 get_config_directory
47
- config_dir = get_config_directory()
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) % 10 == 0:
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
- start = datetime.fromisoformat(self.messages[0]["timestamp"])
250
- end = datetime.fromisoformat(self.messages[-1]["timestamp"])
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 = datetime.now().strftime("%Y%m%d_%H%M%S")
279
- filename = f"conversation_{self.current_session_id[:8]}_{timestamp}.json"
280
-
281
- filepath = self.conversations_dir / filename
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
- self.conversation_metadata = data.get("metadata", {})
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 = str(uuid4())
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