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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.2.dist-info/METADATA +476 -0
- minion_code-0.1.2.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
- minion_code-0.1.2.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {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)
|