minion-code 0.1.0__py3-none-any.whl → 0.1.2__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 (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.2.dist-info/METADATA +476 -0
  98. minion_code-0.1.2.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.2.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Output truncation and size checking utilities for tools.
5
+
6
+ Handles:
7
+ - Built-in tool output truncation (400KB limit)
8
+ - MCP tool output checking (token limit)
9
+ - File size checking before read (with suggested tools for large files)
10
+ - Large output saving to temp files for later retrieval
11
+ """
12
+
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ # ============ 配置常量 ============
19
+ MAX_OUTPUT_SIZE = 400 * 1024 # 400KB - 内置工具输出截断阈值
20
+ MAX_FILE_SIZE = 1_000_000 # 1MB - 文件读取大小阈值
21
+ MAX_TOKEN_LIMIT = 100_000 # MCP 工具 token 限制
22
+ CACHE_DIR = Path.home() / ".minion-code" / "cache" # 大输出缓存目录
23
+
24
+
25
+ # ============ 异常类 ============
26
+ class OutputTooLargeError(Exception):
27
+ """内置工具输出过大"""
28
+
29
+ pass
30
+
31
+
32
+ class MCPContentTooLargeError(Exception):
33
+ """MCP 工具内容过大"""
34
+
35
+ def __init__(self, message: str, token_count: Optional[int] = None):
36
+ self.token_count = token_count
37
+ super().__init__(message)
38
+
39
+
40
+ class FileTooLargeError(Exception):
41
+ """文件过大,建议使用专用工具"""
42
+
43
+ def __init__(
44
+ self,
45
+ message: str,
46
+ file_path: str,
47
+ file_size: int,
48
+ suggested_tool: Optional[str] = None,
49
+ ):
50
+ self.file_path = file_path
51
+ self.file_size = file_size
52
+ self.suggested_tool = suggested_tool
53
+ super().__init__(message)
54
+
55
+
56
+ # ============ 大输出缓存 ============
57
+ def save_large_output(content: str, tool_name: str = "unknown") -> str:
58
+ """
59
+ Save large output to temp file and return the path.
60
+
61
+ Args:
62
+ content: The full output content to save
63
+ tool_name: Name of the tool that generated the output
64
+
65
+ Returns:
66
+ Absolute path to the saved file
67
+ """
68
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
69
+
70
+ file_id = str(uuid.uuid4())[:8]
71
+ filename = f"tool-output-{tool_name}-{file_id}.txt"
72
+ file_path = CACHE_DIR / filename
73
+
74
+ file_path.write_text(content, encoding="utf-8")
75
+ return str(file_path)
76
+
77
+
78
+ def cleanup_cache(max_age_hours: int = 24) -> int:
79
+ """
80
+ Clean up old cached tool outputs.
81
+
82
+ Args:
83
+ max_age_hours: Maximum age in hours before file is deleted
84
+
85
+ Returns:
86
+ Number of files deleted
87
+ """
88
+ if not CACHE_DIR.exists():
89
+ return 0
90
+
91
+ cutoff = time.time() - (max_age_hours * 3600)
92
+ deleted = 0
93
+
94
+ for f in CACHE_DIR.glob("tool-output-*.txt"):
95
+ try:
96
+ if f.stat().st_mtime < cutoff:
97
+ f.unlink()
98
+ deleted += 1
99
+ except OSError:
100
+ pass # File may have been deleted by another process
101
+
102
+ return deleted
103
+
104
+
105
+ # ============ 执行前检查 ============
106
+ def check_file_size_before_read(file_path: str, max_size: int = MAX_FILE_SIZE) -> None:
107
+ """
108
+ Read 工具执行前检查文件大小
109
+
110
+ Args:
111
+ file_path: 文件路径
112
+ max_size: 最大允许大小(字节)
113
+
114
+ Raises:
115
+ FileTooLargeError: 文件过大时抛出,包含建议工具
116
+ """
117
+ path = Path(file_path)
118
+ if not path.exists():
119
+ return
120
+
121
+ file_size = path.stat().st_size
122
+ if file_size > max_size:
123
+ size_mb = file_size / 1_000_000
124
+ suffix = path.suffix.lower()
125
+
126
+ # 根据文件类型建议专用工具
127
+ tool_suggestions = {
128
+ ".pdf": "pdf 工具",
129
+ ".xlsx": "xlsx 工具",
130
+ ".xls": "xlsx 工具",
131
+ ".docx": "docx 工具",
132
+ ".doc": "docx 工具",
133
+ ".pptx": "pptx 工具",
134
+ }
135
+ suggested = tool_suggestions.get(suffix, "分页读取 (offset/limit 参数)")
136
+
137
+ raise FileTooLargeError(
138
+ f"文件过大 ({size_mb:.1f}MB > {max_size/1_000_000:.1f}MB),请使用 {suggested}",
139
+ file_path=str(path),
140
+ file_size=file_size,
141
+ suggested_tool=suggested,
142
+ )
143
+
144
+
145
+ # ============ 输出截断 ============
146
+ def truncate_output(
147
+ output: str,
148
+ max_size: int = MAX_OUTPUT_SIZE,
149
+ tool_name: str = "",
150
+ save_full: bool = True,
151
+ ) -> str:
152
+ """
153
+ 截断内置工具输出,并可选保存完整内容到文件
154
+
155
+ Args:
156
+ output: 原始输出
157
+ max_size: 最大字节数,默认 400KB
158
+ tool_name: 工具名,用于生成针对性提示和文件名
159
+ save_full: 是否保存完整内容到临时文件 (默认 True)
160
+
161
+ Returns:
162
+ 截断后的输出(如果需要截断则添加提示和文件引用)
163
+ """
164
+ output_bytes = output.encode("utf-8")
165
+ if len(output_bytes) <= max_size:
166
+ return output
167
+
168
+ # Save full content to file if enabled
169
+ saved_path = None
170
+ if save_full:
171
+ try:
172
+ saved_path = save_large_output(output, tool_name or "unknown")
173
+ except Exception:
174
+ pass # Silently fail, truncation will still work
175
+
176
+ # 截断到 max_size 字节,确保不截断 UTF-8 字符
177
+ truncated_bytes = output_bytes[:max_size]
178
+ truncated = truncated_bytes.decode("utf-8", errors="ignore")
179
+
180
+ total_size = len(output_bytes)
181
+ size_kb = total_size / 1024
182
+ max_kb = max_size / 1024
183
+
184
+ # Build truncation message
185
+ truncated += f"\n\n---\n⚠️ Output truncated ({size_kb:.0f}KB > {max_kb:.0f}KB limit)"
186
+
187
+ if saved_path:
188
+ truncated += f"\nFull output saved to: {saved_path}"
189
+ truncated += f"\nUse file_read with offset/limit to read in chunks."
190
+ else:
191
+ hint = _get_tool_hint(tool_name)
192
+ truncated += f"\n{hint}"
193
+
194
+ return truncated
195
+
196
+
197
+ def check_mcp_output(output: str, max_tokens: int = MAX_TOKEN_LIMIT) -> str:
198
+ """
199
+ 检查 MCP 工具输出,超过 token 限制则抛异常
200
+
201
+ Args:
202
+ output: MCP 工具输出
203
+ max_tokens: 最大 token 数
204
+
205
+ Returns:
206
+ 原始输出(如果未超限)
207
+
208
+ Raises:
209
+ MCPContentTooLargeError: 输出超过 token 限制
210
+ """
211
+ # 简单估算: 1 token ≈ 4 字符
212
+ estimated_tokens = len(output) // 4
213
+
214
+ if estimated_tokens > max_tokens:
215
+ raise MCPContentTooLargeError(
216
+ f"MCP 工具输出过大 (约 {estimated_tokens} tokens > {max_tokens} 限制)",
217
+ token_count=estimated_tokens,
218
+ )
219
+
220
+ return output
221
+
222
+
223
+ def _get_tool_hint(tool_name: str) -> str:
224
+ """根据工具名返回获取完整内容的提示"""
225
+ hints = {
226
+ "bash": "提示: 使用 `| head -n N` 或 `| tail -n N` 限制输出行数",
227
+ "grep": "提示: 使用 `head_limit` 参数,或更精确的搜索模式",
228
+ "glob": "提示: 使用更具体的 pattern 缩小匹配范围",
229
+ "ls": "提示: 避免递归模式,或指定更具体的子目录",
230
+ "file_read": "提示: 使用 `offset` 和 `limit` 参数分页读取",
231
+ "python": "提示: 在代码中控制 print 输出量",
232
+ }
233
+ return hints.get(tool_name, "提示: 使用更精确的参数缩小输出范围")
@@ -0,0 +1,369 @@
1
+ """Session storage utilities for minion-code.
2
+
3
+ This module provides session persistence functionality, allowing users to
4
+ save and restore conversation sessions.
5
+
6
+ Storage location: ~/.minion-code/sessions/<session_id>.json
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import uuid
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+ from dataclasses import dataclass, asdict, field
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class SessionMessage:
23
+ """A single message in a session."""
24
+
25
+ role: str # 'user' or 'assistant'
26
+ content: str
27
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
28
+
29
+
30
+ @dataclass
31
+ class SessionMetadata:
32
+ """Metadata for a session."""
33
+
34
+ session_id: str
35
+ created_at: str
36
+ updated_at: str
37
+ project_path: str
38
+ message_count: int = 0
39
+ title: Optional[str] = None # First user message as title
40
+
41
+
42
+ @dataclass
43
+ class Session:
44
+ """A complete session with metadata and messages.
45
+
46
+ Attributes:
47
+ metadata: Session metadata (id, timestamps, etc.)
48
+ messages: Original messages (UI display, only grows, never truncated)
49
+ agent_history: Compacted history for agent context (synced after auto-compact)
50
+ compaction_count: Number of times this session has been compacted
51
+ """
52
+
53
+ metadata: SessionMetadata
54
+ messages: List[SessionMessage] = field(default_factory=list)
55
+ agent_history: List[Dict[str, Any]] = field(default_factory=list)
56
+ compaction_count: int = 0
57
+
58
+
59
+ class SessionStorage:
60
+ """Session storage manager for minion-code."""
61
+
62
+ def __init__(self, storage_dir: Optional[Path] = None):
63
+ """Initialize session storage.
64
+
65
+ Args:
66
+ storage_dir: Directory for session files. Defaults to ~/.minion-code/sessions
67
+ """
68
+ if storage_dir is None:
69
+ storage_dir = Path.home() / ".minion-code" / "sessions"
70
+
71
+ self.storage_dir = Path(storage_dir)
72
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ def _get_session_path(self, session_id: str) -> Path:
75
+ """Get the file path for a session."""
76
+ return self.storage_dir / f"{session_id}.json"
77
+
78
+ def generate_session_id(self) -> str:
79
+ """Generate a new unique session ID."""
80
+ return str(uuid.uuid4())[:8] # Short ID for readability
81
+
82
+ def save_session(self, session: Session) -> None:
83
+ """Save a session to disk.
84
+
85
+ Args:
86
+ session: Session object to save
87
+ """
88
+ session_path = self._get_session_path(session.metadata.session_id)
89
+
90
+ # Update the updated_at timestamp
91
+ session.metadata.updated_at = datetime.now().isoformat()
92
+ session.metadata.message_count = len(session.messages)
93
+
94
+ # Set title from first user message if not set
95
+ if not session.metadata.title and session.messages:
96
+ for msg in session.messages:
97
+ if msg.role == "user":
98
+ # Use first 50 chars of first user message as title
99
+ session.metadata.title = msg.content[:50]
100
+ if len(msg.content) > 50:
101
+ session.metadata.title += "..."
102
+ break
103
+
104
+ try:
105
+ # Convert to dict for JSON serialization
106
+ session_dict = {
107
+ "metadata": asdict(session.metadata),
108
+ "messages": [asdict(msg) for msg in session.messages],
109
+ "agent_history": session.agent_history, # Already list of dicts
110
+ "compaction_count": session.compaction_count,
111
+ }
112
+
113
+ with open(session_path, "w", encoding="utf-8") as f:
114
+ json.dump(session_dict, f, indent=2, ensure_ascii=False)
115
+
116
+ logger.debug(
117
+ f"Saved session {session.metadata.session_id} to {session_path}"
118
+ )
119
+ except Exception as e:
120
+ logger.error(f"Failed to save session {session.metadata.session_id}: {e}")
121
+ raise
122
+
123
+ def load_session(self, session_id: str) -> Optional[Session]:
124
+ """Load a session from disk.
125
+
126
+ Args:
127
+ session_id: ID of the session to load
128
+
129
+ Returns:
130
+ Session object if found, None otherwise
131
+ """
132
+ session_path = self._get_session_path(session_id)
133
+
134
+ if not session_path.exists():
135
+ logger.warning(f"Session {session_id} not found at {session_path}")
136
+ return None
137
+
138
+ try:
139
+ with open(session_path, "r", encoding="utf-8") as f:
140
+ session_dict = json.load(f)
141
+
142
+ # Convert dict back to dataclass
143
+ metadata = SessionMetadata(**session_dict["metadata"])
144
+ messages = [
145
+ SessionMessage(**msg) for msg in session_dict.get("messages", [])
146
+ ]
147
+ # Load new fields with backward compatibility (defaults for old sessions)
148
+ agent_history = session_dict.get("agent_history", [])
149
+ compaction_count = session_dict.get("compaction_count", 0)
150
+
151
+ return Session(
152
+ metadata=metadata,
153
+ messages=messages,
154
+ agent_history=agent_history,
155
+ compaction_count=compaction_count,
156
+ )
157
+ except Exception as e:
158
+ logger.error(f"Failed to load session {session_id}: {e}")
159
+ return None
160
+
161
+ def get_latest_session_id(
162
+ self, project_path: Optional[str] = None
163
+ ) -> Optional[str]:
164
+ """Get the ID of the most recent session.
165
+
166
+ Args:
167
+ project_path: If provided, filter by project path
168
+
169
+ Returns:
170
+ Session ID if found, None otherwise
171
+ """
172
+ sessions = self.list_sessions(project_path=project_path)
173
+ if not sessions:
174
+ return None
175
+
176
+ # Sessions are sorted by updated_at descending
177
+ return sessions[0].session_id
178
+
179
+ def list_sessions(
180
+ self, project_path: Optional[str] = None, limit: int = 20
181
+ ) -> List[SessionMetadata]:
182
+ """List available sessions.
183
+
184
+ Args:
185
+ project_path: If provided, filter by project path
186
+ limit: Maximum number of sessions to return
187
+
188
+ Returns:
189
+ List of SessionMetadata, sorted by updated_at descending
190
+ """
191
+ sessions = []
192
+
193
+ try:
194
+ for session_file in self.storage_dir.glob("*.json"):
195
+ try:
196
+ with open(session_file, "r", encoding="utf-8") as f:
197
+ session_dict = json.load(f)
198
+
199
+ metadata = SessionMetadata(**session_dict["metadata"])
200
+
201
+ # Filter by project_path if specified
202
+ if project_path and metadata.project_path != project_path:
203
+ continue
204
+
205
+ sessions.append(metadata)
206
+ except Exception as e:
207
+ logger.warning(f"Failed to read session file {session_file}: {e}")
208
+ continue
209
+
210
+ # Sort by updated_at descending (most recent first)
211
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
212
+
213
+ return sessions[:limit]
214
+ except Exception as e:
215
+ logger.error(f"Failed to list sessions: {e}")
216
+ return []
217
+
218
+ def delete_session(self, session_id: str) -> bool:
219
+ """Delete a session.
220
+
221
+ Args:
222
+ session_id: ID of the session to delete
223
+
224
+ Returns:
225
+ True if deleted, False if not found
226
+ """
227
+ session_path = self._get_session_path(session_id)
228
+
229
+ if not session_path.exists():
230
+ return False
231
+
232
+ try:
233
+ session_path.unlink()
234
+ logger.info(f"Deleted session {session_id}")
235
+ return True
236
+ except Exception as e:
237
+ logger.error(f"Failed to delete session {session_id}: {e}")
238
+ return False
239
+
240
+ def create_session(self, project_path: Optional[str] = None) -> Session:
241
+ """Create a new session.
242
+
243
+ Args:
244
+ project_path: Path of the project for this session
245
+
246
+ Returns:
247
+ New Session object
248
+ """
249
+ now = datetime.now().isoformat()
250
+ session_id = self.generate_session_id()
251
+
252
+ if project_path is None:
253
+ project_path = os.getcwd()
254
+
255
+ metadata = SessionMetadata(
256
+ session_id=session_id,
257
+ created_at=now,
258
+ updated_at=now,
259
+ project_path=project_path,
260
+ message_count=0,
261
+ )
262
+
263
+ return Session(metadata=metadata, messages=[])
264
+
265
+ def add_message(
266
+ self, session: Session, role: str, content: str, auto_save: bool = True
267
+ ) -> None:
268
+ """Add a message to a session.
269
+
270
+ Args:
271
+ session: Session to add message to
272
+ role: 'user' or 'assistant'
273
+ content: Message content
274
+ auto_save: If True, save session after adding message
275
+ """
276
+ message = SessionMessage(role=role, content=content)
277
+ session.messages.append(message)
278
+
279
+ if auto_save:
280
+ self.save_session(session)
281
+
282
+
283
+ # Global instance
284
+ session_storage = SessionStorage()
285
+
286
+
287
+ # Convenience functions
288
+ def create_session(project_path: Optional[str] = None) -> Session:
289
+ """Create a new session."""
290
+ return session_storage.create_session(project_path)
291
+
292
+
293
+ def save_session(session: Session) -> None:
294
+ """Save a session."""
295
+ session_storage.save_session(session)
296
+
297
+
298
+ def load_session(session_id: str) -> Optional[Session]:
299
+ """Load a session by ID."""
300
+ return session_storage.load_session(session_id)
301
+
302
+
303
+ def get_latest_session_id(project_path: Optional[str] = None) -> Optional[str]:
304
+ """Get the most recent session ID."""
305
+ return session_storage.get_latest_session_id(project_path)
306
+
307
+
308
+ def list_sessions(
309
+ project_path: Optional[str] = None, limit: int = 20
310
+ ) -> List[SessionMetadata]:
311
+ """List available sessions."""
312
+ return session_storage.list_sessions(project_path, limit)
313
+
314
+
315
+ def add_message(
316
+ session: Session, role: str, content: str, auto_save: bool = True
317
+ ) -> None:
318
+ """Add a message to a session."""
319
+ session_storage.add_message(session, role, content, auto_save)
320
+
321
+
322
+ def restore_agent_history(agent, session: Session, verbose: bool = False) -> int:
323
+ """Restore agent's conversation history from session.
324
+
325
+ Prefers `agent_history` (compacted) over `messages` (original) to avoid
326
+ repeated compaction on session restore.
327
+
328
+ Args:
329
+ agent: The agent instance with state.history
330
+ session: Session to restore from
331
+ verbose: Print debug info
332
+
333
+ Returns:
334
+ Number of messages restored
335
+ """
336
+ if not agent or not session:
337
+ return 0
338
+
339
+ # Check if agent has history
340
+ if not hasattr(agent, "state") or not hasattr(agent.state, "history"):
341
+ return 0
342
+
343
+ # Clear existing history first
344
+ agent.state.history.clear()
345
+
346
+ # Prefer agent_history (compacted) if available
347
+ if session.agent_history:
348
+ for msg in session.agent_history:
349
+ agent.state.history.append(msg)
350
+
351
+ if verbose:
352
+ print(
353
+ f"Restored {len(session.agent_history)} messages from agent_history "
354
+ f"(compacted {session.compaction_count} times)"
355
+ )
356
+
357
+ return len(session.agent_history)
358
+
359
+ # Fallback to original messages (first time or old sessions without agent_history)
360
+ if not session.messages:
361
+ return 0
362
+
363
+ for msg in session.messages:
364
+ agent.state.history.append({"role": msg.role, "content": msg.content})
365
+
366
+ if verbose:
367
+ print(f"Restored {len(session.messages)} messages from original history")
368
+
369
+ return len(session.messages)