devlake-mcp 0.4.1__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.
- devlake_mcp/__init__.py +7 -0
- devlake_mcp/__main__.py +10 -0
- devlake_mcp/cli.py +794 -0
- devlake_mcp/client.py +474 -0
- devlake_mcp/compat.py +165 -0
- devlake_mcp/config.py +204 -0
- devlake_mcp/constants.py +161 -0
- devlake_mcp/enums.py +58 -0
- devlake_mcp/generation_manager.py +296 -0
- devlake_mcp/git_utils.py +489 -0
- devlake_mcp/hooks/__init__.py +49 -0
- devlake_mcp/hooks/hook_utils.py +246 -0
- devlake_mcp/hooks/post_tool_use.py +325 -0
- devlake_mcp/hooks/pre_tool_use.py +110 -0
- devlake_mcp/hooks/record_session.py +183 -0
- devlake_mcp/hooks/session_start.py +81 -0
- devlake_mcp/hooks/stop.py +275 -0
- devlake_mcp/hooks/transcript_utils.py +547 -0
- devlake_mcp/hooks/user_prompt_submit.py +204 -0
- devlake_mcp/logging_config.py +202 -0
- devlake_mcp/retry_queue.py +556 -0
- devlake_mcp/server.py +664 -0
- devlake_mcp/session_manager.py +444 -0
- devlake_mcp/utils.py +225 -0
- devlake_mcp/version_utils.py +174 -0
- devlake_mcp-0.4.1.dist-info/METADATA +541 -0
- devlake_mcp-0.4.1.dist-info/RECORD +31 -0
- devlake_mcp-0.4.1.dist-info/WHEEL +5 -0
- devlake_mcp-0.4.1.dist-info/entry_points.txt +3 -0
- devlake_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- devlake_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
会话生命周期管理模块
|
|
5
|
+
|
|
6
|
+
功能:
|
|
7
|
+
1. 跟踪当前活跃的 session_id/conversation_id
|
|
8
|
+
2. 检测会话切换(ID变化)
|
|
9
|
+
3. 自动创建新会话(调用 API)
|
|
10
|
+
4. 自动结束旧会话(调用 API 更新 session_end_time)
|
|
11
|
+
5. 支持 Cursor 和 Claude Code 两种模式
|
|
12
|
+
|
|
13
|
+
使用方式:
|
|
14
|
+
from devlake_mcp.session_manager import start_new_session, check_and_switch_session
|
|
15
|
+
|
|
16
|
+
# 1. SessionStart hook:强制开始新会话
|
|
17
|
+
start_new_session(
|
|
18
|
+
session_id=session_id,
|
|
19
|
+
cwd=cwd,
|
|
20
|
+
ide_type='claude_code'
|
|
21
|
+
)
|
|
22
|
+
# 无论如何都会创建新会话(结束旧的 + 创建新的)
|
|
23
|
+
|
|
24
|
+
# 2. UserPromptSubmit hook:智能判断
|
|
25
|
+
check_and_switch_session(
|
|
26
|
+
new_session_id=session_id,
|
|
27
|
+
cwd=cwd,
|
|
28
|
+
ide_type='claude_code'
|
|
29
|
+
)
|
|
30
|
+
# 会智能处理:
|
|
31
|
+
# - 首次会话:创建 session
|
|
32
|
+
# - 会话延续:什么都不做
|
|
33
|
+
# - 会话切换:结束旧的,创建新的
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
from typing import Optional, Dict, Union
|
|
41
|
+
|
|
42
|
+
from .client import DevLakeClient
|
|
43
|
+
from .utils import get_data_dir
|
|
44
|
+
from .retry_queue import save_failed_upload
|
|
45
|
+
from .git_utils import get_git_info, get_git_repo_path
|
|
46
|
+
from .version_utils import detect_platform_info
|
|
47
|
+
from .enums import IDEType
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
# 配置日志
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ============================================================================
|
|
55
|
+
# 辅助函数
|
|
56
|
+
# ============================================================================
|
|
57
|
+
|
|
58
|
+
def _get_current_pid() -> int:
|
|
59
|
+
"""获取当前进程 PID"""
|
|
60
|
+
return os.getpid()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_state_file(ide_type: Union[IDEType, str]) -> Path:
|
|
64
|
+
"""
|
|
65
|
+
获取当前进程的状态文件路径(进程级隔离)
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
ide_type: IDE 类型枚举或字符串
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
状态文件路径
|
|
72
|
+
|
|
73
|
+
格式: ~/.devlake/session_<ide_type>_<pid>.json
|
|
74
|
+
例如: ~/.devlake/session_claude_code_12345.json
|
|
75
|
+
|
|
76
|
+
说明:
|
|
77
|
+
使用进程级隔离 + 持久化存储确保:
|
|
78
|
+
1. 不同 IDE 类型(Claude Code/Cursor)互不干扰
|
|
79
|
+
2. 同一 IDE 的多个实例互不干扰
|
|
80
|
+
3. 无需文件锁,每个进程有独立状态文件
|
|
81
|
+
4. 状态文件不会被系统自动清理(使用 ~/.devlake 而非 /tmp)
|
|
82
|
+
"""
|
|
83
|
+
pid = _get_current_pid()
|
|
84
|
+
ide_type_str = ide_type.value if isinstance(ide_type, IDEType) else str(ide_type)
|
|
85
|
+
filename = f'session_{ide_type_str}_{pid}.json'
|
|
86
|
+
return get_data_dir(persistent=True) / filename
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ============================================================================
|
|
90
|
+
# 状态文件管理
|
|
91
|
+
# ============================================================================
|
|
92
|
+
|
|
93
|
+
def _read_state(ide_type: Union[IDEType, str]) -> Optional[Dict]:
|
|
94
|
+
"""
|
|
95
|
+
从状态文件读取当前会话信息
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
ide_type: IDE 类型枚举
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
会话状态字典,如果文件不存在或损坏返回 None
|
|
102
|
+
|
|
103
|
+
状态格式:
|
|
104
|
+
{
|
|
105
|
+
"session_id": "abc-123",
|
|
106
|
+
"ide_type": "cursor",
|
|
107
|
+
"started_at": "2025-01-08T10:00:00"
|
|
108
|
+
}
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
state_file = _get_state_file(ide_type)
|
|
112
|
+
if state_file.exists():
|
|
113
|
+
with open(state_file, 'r', encoding='utf-8') as f:
|
|
114
|
+
return json.load(f)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f'读取会话状态文件失败: {e}')
|
|
117
|
+
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_state(session_id: str, ide_type: Union[IDEType, str]):
|
|
122
|
+
"""
|
|
123
|
+
写入会话状态到文件
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
session_id: 会话 ID
|
|
127
|
+
ide_type: IDE 类型枚举
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
ide_type_str = ide_type.value if isinstance(ide_type, IDEType) else str(ide_type)
|
|
131
|
+
state = {
|
|
132
|
+
'session_id': session_id,
|
|
133
|
+
'ide_type': ide_type_str,
|
|
134
|
+
'started_at': datetime.now().isoformat()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
state_file = _get_state_file(ide_type)
|
|
138
|
+
|
|
139
|
+
# 确保目录存在
|
|
140
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
with open(state_file, 'w', encoding='utf-8') as f:
|
|
143
|
+
json.dump(state, f, ensure_ascii=False, indent=2)
|
|
144
|
+
|
|
145
|
+
logger.debug(f'会话状态已保存: {session_id} ({ide_type_str}), 文件: {state_file.name}')
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f'保存会话状态失败: {e}')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _clear_state(ide_type: Union[IDEType, str]):
|
|
151
|
+
"""
|
|
152
|
+
清空状态文件
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
ide_type: IDE 类型枚举
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
state_file = _get_state_file(ide_type)
|
|
159
|
+
if state_file.exists():
|
|
160
|
+
state_file.unlink()
|
|
161
|
+
logger.debug(f'会话状态文件已清空: {state_file.name}')
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(f'清空会话状态文件失败: {e}')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ============================================================================
|
|
167
|
+
# 公开 API
|
|
168
|
+
# ============================================================================
|
|
169
|
+
|
|
170
|
+
def get_active_session(ide_type: IDEType = IDEType.CLAUDE_CODE) -> Optional[str]:
|
|
171
|
+
"""
|
|
172
|
+
获取当前活跃的 session_id
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
当前活跃的 session_id,如果没有活跃会话返回 None
|
|
179
|
+
"""
|
|
180
|
+
state = _read_state(ide_type)
|
|
181
|
+
return state.get('session_id') if state else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def set_active_session(session_id: str, ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE):
|
|
185
|
+
"""
|
|
186
|
+
设置当前活跃会话
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
session_id: 会话 ID
|
|
190
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
191
|
+
"""
|
|
192
|
+
_write_state(session_id, ide_type)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _create_session_record(session_id: str, cwd: str, ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE):
|
|
196
|
+
"""
|
|
197
|
+
创建 session 记录(上传到 DevLake API)
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
session_id: Session ID
|
|
201
|
+
cwd: 当前工作目录
|
|
202
|
+
ide_type: IDE 类型枚举
|
|
203
|
+
"""
|
|
204
|
+
session_data = None
|
|
205
|
+
try:
|
|
206
|
+
ide_type_str = ide_type.value if isinstance(ide_type, IDEType) else str(ide_type)
|
|
207
|
+
|
|
208
|
+
# 1. 获取 Git 信息(动态 + 静态)
|
|
209
|
+
git_info = get_git_info(cwd, timeout=1, include_user_info=True)
|
|
210
|
+
git_branch = git_info.get('git_branch', 'unknown')
|
|
211
|
+
git_commit = git_info.get('git_commit', 'unknown')
|
|
212
|
+
git_author = git_info.get('git_author', 'unknown')
|
|
213
|
+
git_email = git_info.get('git_email', 'unknown')
|
|
214
|
+
|
|
215
|
+
# 2. 获取 Git 仓库路径(namespace/name)
|
|
216
|
+
git_repo_path = get_git_repo_path(cwd)
|
|
217
|
+
|
|
218
|
+
# 3. 从 git_repo_path 提取 project_name
|
|
219
|
+
project_name = git_repo_path.split('/')[-1] if '/' in git_repo_path else git_repo_path
|
|
220
|
+
|
|
221
|
+
# 4. 检测平台信息和版本
|
|
222
|
+
platform_info = detect_platform_info(ide_type=ide_type)
|
|
223
|
+
|
|
224
|
+
# 5. 构造 session 数据
|
|
225
|
+
session_data = {
|
|
226
|
+
'session_id': session_id,
|
|
227
|
+
'user_name': git_author,
|
|
228
|
+
'ide_type': ide_type_str,
|
|
229
|
+
'model_name': os.getenv('CLAUDE_MODEL', 'claude-sonnet-4-5'),
|
|
230
|
+
'git_repo_path': git_repo_path,
|
|
231
|
+
'project_name': project_name,
|
|
232
|
+
'cwd': cwd,
|
|
233
|
+
'session_start_time': datetime.now().isoformat(),
|
|
234
|
+
'conversation_rounds': 0,
|
|
235
|
+
'is_adopted': 0,
|
|
236
|
+
'git_branch': git_branch,
|
|
237
|
+
'git_commit': git_commit,
|
|
238
|
+
'git_author': git_author,
|
|
239
|
+
'git_email': git_email,
|
|
240
|
+
# 新增:版本信息
|
|
241
|
+
'devlake_mcp_version': platform_info['devlake_mcp_version'],
|
|
242
|
+
'ide_version': platform_info['ide_version'],
|
|
243
|
+
'data_source': platform_info['data_source']
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
f'准备创建 Session: {session_id}, '
|
|
248
|
+
f'repo: {git_repo_path}, branch: {git_branch}, '
|
|
249
|
+
f'ide: {ide_type_str} {platform_info["ide_version"] or "unknown"}, '
|
|
250
|
+
f'devlake-mcp: {platform_info["devlake_mcp_version"]}'
|
|
251
|
+
)
|
|
252
|
+
logger.debug(f'session_data 内容: {json.dumps(session_data, ensure_ascii=False, indent=2)}')
|
|
253
|
+
|
|
254
|
+
# 5. 调用 DevLake API 创建 session
|
|
255
|
+
with DevLakeClient() as client:
|
|
256
|
+
client.post('/api/ai-coding/sessions', session_data)
|
|
257
|
+
|
|
258
|
+
logger.info(f'成功创建 Session: {session_id}')
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
# API 调用失败,记录错误但不阻塞
|
|
262
|
+
logger.error(f'创建 Session 失败 ({session_id}): {e}')
|
|
263
|
+
# 保存到本地队列(支持自动重试)
|
|
264
|
+
if session_data:
|
|
265
|
+
save_failed_upload(
|
|
266
|
+
queue_type='session',
|
|
267
|
+
data=session_data,
|
|
268
|
+
error=str(e)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def start_new_session(session_id: str, cwd: str, ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE):
|
|
273
|
+
"""
|
|
274
|
+
强制开始新会话(SessionStart 专用)
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
session_id: 会话 ID
|
|
278
|
+
cwd: 当前工作目录(用于获取 Git 信息)
|
|
279
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
280
|
+
|
|
281
|
+
行为:
|
|
282
|
+
1. 如果有活跃会话,先结束它(无论 session_id 是否相同)
|
|
283
|
+
2. 创建新会话并设置为活跃
|
|
284
|
+
|
|
285
|
+
用途:
|
|
286
|
+
SessionStart hook 调用,明确表示"新会话开始"
|
|
287
|
+
"""
|
|
288
|
+
# 参数验证
|
|
289
|
+
if not session_id:
|
|
290
|
+
logger.warning('session_id 为空,跳过')
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
# 转换为枚举类型(如果是字符串)
|
|
294
|
+
ide_type_enum = IDEType.from_string(ide_type) if isinstance(ide_type, str) else ide_type
|
|
295
|
+
|
|
296
|
+
# 读取当前状态(进程级)
|
|
297
|
+
state = _read_state(ide_type_enum)
|
|
298
|
+
|
|
299
|
+
# 如果有活跃会话,先结束它
|
|
300
|
+
if state:
|
|
301
|
+
old_session_id = state.get('session_id')
|
|
302
|
+
old_ide_type = state.get('ide_type', 'unknown')
|
|
303
|
+
|
|
304
|
+
if old_session_id == session_id:
|
|
305
|
+
logger.info(f'重新开始会话(相同 ID): {session_id}')
|
|
306
|
+
else:
|
|
307
|
+
logger.info(f'结束旧会话: {old_session_id} → 开始新会话: {session_id}')
|
|
308
|
+
|
|
309
|
+
# 结束旧会话(old_ide_type 是字符串,会被 end_session 转换)
|
|
310
|
+
end_session(old_session_id, old_ide_type)
|
|
311
|
+
|
|
312
|
+
# 创建新会话
|
|
313
|
+
logger.info(f'创建新会话: {session_id} ({ide_type_enum.value})')
|
|
314
|
+
_create_session_record(session_id, cwd, ide_type_enum)
|
|
315
|
+
set_active_session(session_id, ide_type_enum)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check_and_switch_session(
|
|
319
|
+
new_session_id: str,
|
|
320
|
+
cwd: str,
|
|
321
|
+
ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE
|
|
322
|
+
) -> bool:
|
|
323
|
+
"""
|
|
324
|
+
检查会话切换,智能处理会话生命周期(UserPromptSubmit 专用)
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
new_session_id: 新的 session_id (Claude Code) 或 conversation_id (Cursor)
|
|
328
|
+
cwd: 当前工作目录(用于获取 Git 信息)
|
|
329
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
bool: 是否发生了会话切换(True 表示切换,False 表示首次或延续)
|
|
333
|
+
|
|
334
|
+
行为:
|
|
335
|
+
1. 如果是首次会话:创建 session 并设置为活跃 → 返回 False
|
|
336
|
+
2. 如果是同一会话:什么都不做(会话延续) → 返回 False
|
|
337
|
+
3. 如果会话切换:结束旧会话,创建并设置新会话 → 返回 True
|
|
338
|
+
|
|
339
|
+
用途:
|
|
340
|
+
UserPromptSubmit hook 调用,智能判断是否需要创建会话
|
|
341
|
+
"""
|
|
342
|
+
# 参数验证
|
|
343
|
+
if not new_session_id:
|
|
344
|
+
logger.warning('new_session_id 为空,跳过会话管理')
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
# 转换为枚举类型(如果是字符串)
|
|
348
|
+
ide_type_enum = IDEType.from_string(ide_type) if isinstance(ide_type, str) else ide_type
|
|
349
|
+
|
|
350
|
+
# 读取当前状态(进程级)
|
|
351
|
+
state = _read_state(ide_type_enum)
|
|
352
|
+
|
|
353
|
+
# 情况1:首次会话
|
|
354
|
+
if not state:
|
|
355
|
+
logger.info(f'首次会话: {new_session_id} ({ide_type_enum.value})')
|
|
356
|
+
_create_session_record(new_session_id, cwd, ide_type_enum)
|
|
357
|
+
set_active_session(new_session_id, ide_type_enum)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
current_session_id = state.get('session_id')
|
|
361
|
+
current_ide_type = state.get('ide_type', 'unknown')
|
|
362
|
+
|
|
363
|
+
# 情况2:同一会话
|
|
364
|
+
if current_session_id == new_session_id:
|
|
365
|
+
logger.debug(f'会话延续: {new_session_id}')
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
# 情况3:会话切换
|
|
369
|
+
logger.info(
|
|
370
|
+
f'检测到会话切换: {current_session_id} ({current_ide_type}) '
|
|
371
|
+
f'-> {new_session_id} ({ide_type_enum.value})'
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# 结束旧会话(current_ide_type 是字符串,会被 end_session 转换)
|
|
375
|
+
end_session(current_session_id, current_ide_type)
|
|
376
|
+
|
|
377
|
+
# 创建新会话
|
|
378
|
+
_create_session_record(new_session_id, cwd, ide_type_enum)
|
|
379
|
+
|
|
380
|
+
# 保存新会话状态
|
|
381
|
+
set_active_session(new_session_id, ide_type_enum)
|
|
382
|
+
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def end_session(session_id: str, ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE):
|
|
387
|
+
"""
|
|
388
|
+
结束指定会话(更新 session_end_time)
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
session_id: 要结束的会话 ID
|
|
392
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
393
|
+
|
|
394
|
+
行为:
|
|
395
|
+
1. 调用 DevLake API 更新 session_end_time
|
|
396
|
+
2. 如果 API 调用失败,保存到重试队列
|
|
397
|
+
3. 不抛出异常,静默失败
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
ide_type_str = ide_type.value if isinstance(ide_type, IDEType) else str(ide_type)
|
|
401
|
+
|
|
402
|
+
# 构造更新数据
|
|
403
|
+
update_data = {
|
|
404
|
+
'session_end_time': datetime.now().isoformat()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
logger.info(f'准备结束会话: {session_id} ({ide_type_str})')
|
|
408
|
+
|
|
409
|
+
# 调用 DevLake API
|
|
410
|
+
with DevLakeClient() as client:
|
|
411
|
+
client.update_session(session_id, update_data)
|
|
412
|
+
|
|
413
|
+
logger.info(f'会话已结束: {session_id}')
|
|
414
|
+
|
|
415
|
+
except Exception as e:
|
|
416
|
+
# API 调用失败,记录错误并保存到重试队列
|
|
417
|
+
logger.error(f'结束会话失败 ({session_id}): {e}')
|
|
418
|
+
|
|
419
|
+
# 保存到重试队列(支持自动重试)
|
|
420
|
+
save_failed_upload(
|
|
421
|
+
queue_type='session_end',
|
|
422
|
+
data={
|
|
423
|
+
'session_id': session_id,
|
|
424
|
+
'ide_type': ide_type_str,
|
|
425
|
+
**update_data
|
|
426
|
+
},
|
|
427
|
+
error=str(e)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def clear_session(ide_type: Union[IDEType, str] = IDEType.CLAUDE_CODE):
|
|
432
|
+
"""
|
|
433
|
+
清空当前会话状态
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
ide_type: IDE 类型 (IDEType 枚举或字符串,默认 Claude Code)
|
|
437
|
+
|
|
438
|
+
用途:
|
|
439
|
+
- 手动清理状态(测试或调试)
|
|
440
|
+
- 重置会话跟踪
|
|
441
|
+
"""
|
|
442
|
+
ide_type_str = ide_type.value if isinstance(ide_type, IDEType) else str(ide_type)
|
|
443
|
+
_clear_state(ide_type)
|
|
444
|
+
logger.info(f'当前会话状态已清空 ({ide_type_str})')
|
devlake_mcp/utils.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
MCP 工具函数模块
|
|
5
|
+
|
|
6
|
+
提供跨工具的通用功能:
|
|
7
|
+
- 临时目录和文件管理
|
|
8
|
+
- 内容压缩(gzip + base64)
|
|
9
|
+
- 文件过滤(排除敏感文件和二进制文件)
|
|
10
|
+
|
|
11
|
+
改进:
|
|
12
|
+
- 完整的类型注解
|
|
13
|
+
- 使用常量配置
|
|
14
|
+
- 更好的文档说明
|
|
15
|
+
- 完善的日志记录
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import gzip
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import tempfile
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from .constants import (
|
|
28
|
+
SENSITIVE_FILE_PATTERNS,
|
|
29
|
+
BINARY_FILE_EXTENSIONS,
|
|
30
|
+
TEMP_DIR_NAME,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 配置日志
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# 持久化数据目录名称
|
|
37
|
+
DATA_DIR_NAME = '.devlake'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_data_dir(persistent: bool = False) -> Path:
|
|
41
|
+
"""
|
|
42
|
+
获取跨平台的数据存储目录
|
|
43
|
+
|
|
44
|
+
优先级:
|
|
45
|
+
1. 环境变量 DEVLAKE_MCP_DATA_DIR (优先级最高,覆盖 persistent 参数)
|
|
46
|
+
2. persistent=True: 用户主目录 ~/.devlake (持久化存储)
|
|
47
|
+
3. persistent=False: 系统临时目录/devlake_mcp (临时存储)
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
persistent: True 使用持久化目录 (~/.devlake),
|
|
51
|
+
False 使用临时目录 (系统temp/devlake_mcp)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
数据存储目录路径
|
|
55
|
+
|
|
56
|
+
持久化目录 (persistent=True):
|
|
57
|
+
- Windows: C:\\Users\\xxx\\.devlake
|
|
58
|
+
- macOS: /Users/xxx/.devlake
|
|
59
|
+
- Linux: /home/xxx/.devlake
|
|
60
|
+
|
|
61
|
+
临时目录 (persistent=False):
|
|
62
|
+
- Windows: C:\\Users\\xxx\\AppData\\Local\\Temp\\devlake_mcp
|
|
63
|
+
- macOS: /var/folders/xxx/T/devlake_mcp
|
|
64
|
+
- Linux: /tmp/devlake_mcp
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> # 持久化存储(session state, generation state, retry queue)
|
|
68
|
+
>>> data_dir = get_data_dir(persistent=True)
|
|
69
|
+
>>> # 临时存储(before_content 快照文件)
|
|
70
|
+
>>> temp_dir = get_data_dir(persistent=False)
|
|
71
|
+
>>> # 自定义路径
|
|
72
|
+
>>> os.environ['DEVLAKE_MCP_DATA_DIR'] = '/custom/path'
|
|
73
|
+
>>> data_dir = get_data_dir() # 返回 /custom/path
|
|
74
|
+
"""
|
|
75
|
+
# 优先使用环境变量
|
|
76
|
+
custom_dir = os.getenv('DEVLAKE_MCP_DATA_DIR')
|
|
77
|
+
if custom_dir:
|
|
78
|
+
data_dir = Path(custom_dir)
|
|
79
|
+
elif persistent:
|
|
80
|
+
# 持久化目录: ~/.devlake
|
|
81
|
+
data_dir = Path.home() / DATA_DIR_NAME
|
|
82
|
+
else:
|
|
83
|
+
# 临时目录: /tmp/devlake_mcp (跨平台兼容)
|
|
84
|
+
data_dir = Path(tempfile.gettempdir()) / TEMP_DIR_NAME
|
|
85
|
+
|
|
86
|
+
# 确保目录存在
|
|
87
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
return data_dir
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_temp_file_path(session_id: str, file_path: str) -> str:
|
|
92
|
+
"""
|
|
93
|
+
生成临时文件路径(用于存储 before_content)
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
session_id: 会话ID
|
|
97
|
+
file_path: 文件路径
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
临时文件路径(格式:{temp_dir}/{session_id}_{file_hash}.before)
|
|
101
|
+
"""
|
|
102
|
+
# 使用文件路径的 hash 作为文件名(避免路径过长)
|
|
103
|
+
file_hash = hashlib.md5(file_path.encode()).hexdigest()[:16]
|
|
104
|
+
|
|
105
|
+
# 获取跨平台临时目录(使用临时存储模式)
|
|
106
|
+
temp_dir = get_data_dir(persistent=False)
|
|
107
|
+
|
|
108
|
+
# 临时文件名:{session_id}_{file_hash}.before
|
|
109
|
+
temp_file = temp_dir / f"{session_id}_{file_hash}.before"
|
|
110
|
+
|
|
111
|
+
return str(temp_file)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def compress_content(content: str) -> str:
|
|
115
|
+
"""
|
|
116
|
+
压缩内容(gzip + base64)
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
content: 原始内容
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
base64 编码的 gzip 压缩内容
|
|
123
|
+
|
|
124
|
+
注意:
|
|
125
|
+
如果压缩失败,会记录错误日志并返回空字符串
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
if not content:
|
|
129
|
+
return ''
|
|
130
|
+
|
|
131
|
+
# 压缩内容
|
|
132
|
+
compressed = gzip.compress(content.encode('utf-8'))
|
|
133
|
+
encoded = base64.b64encode(compressed).decode('ascii')
|
|
134
|
+
|
|
135
|
+
# 记录压缩效果(仅在调试模式下)
|
|
136
|
+
original_size = len(content.encode('utf-8'))
|
|
137
|
+
compressed_size = len(encoded)
|
|
138
|
+
compression_ratio = compressed_size / original_size if original_size > 0 else 0
|
|
139
|
+
|
|
140
|
+
logger.debug(
|
|
141
|
+
f"内容压缩完成: {original_size}B -> {compressed_size}B "
|
|
142
|
+
f"(压缩率: {compression_ratio:.1%})"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return encoded
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"压缩内容失败: {e}", exc_info=True)
|
|
149
|
+
return ''
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def should_collect_file(file_path: str) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
判断是否应该采集该文件
|
|
155
|
+
|
|
156
|
+
排除规则:
|
|
157
|
+
1. 敏感文件:.env, .secret, .key 等
|
|
158
|
+
2. 二进制文件:图片、压缩包、可执行文件等
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
file_path: 文件路径
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True 表示应该采集,False 表示跳过
|
|
165
|
+
"""
|
|
166
|
+
# 排除敏感文件(使用常量配置)
|
|
167
|
+
file_path_lower = file_path.lower()
|
|
168
|
+
|
|
169
|
+
for pattern in SENSITIVE_FILE_PATTERNS:
|
|
170
|
+
if pattern in file_path_lower:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# 排除二进制文件(通过后缀判断,使用常量配置)
|
|
174
|
+
file_ext = Path(file_path).suffix.lower()
|
|
175
|
+
if file_ext in BINARY_FILE_EXTENSIONS:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_file_type(file_path: str) -> str:
|
|
182
|
+
"""
|
|
183
|
+
获取文件类型(扩展名)
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
file_path: 文件路径
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
文件扩展名(不含点),如果没有扩展名返回 'unknown'
|
|
190
|
+
"""
|
|
191
|
+
return Path(file_path).suffix.lstrip('.') or 'unknown'
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_file_content(file_path: str) -> str:
|
|
195
|
+
"""
|
|
196
|
+
读取文件内容
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
file_path: 文件路径
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
文件内容,读取失败返回空字符串
|
|
203
|
+
|
|
204
|
+
注意:
|
|
205
|
+
如果文件不存在或读取失败,会记录警告日志并返回空字符串
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
if not os.path.exists(file_path):
|
|
209
|
+
logger.debug(f"文件不存在: {file_path}")
|
|
210
|
+
return ''
|
|
211
|
+
|
|
212
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
213
|
+
content = f.read()
|
|
214
|
+
logger.debug(f"成功读取文件: {file_path} ({len(content)} 字符)")
|
|
215
|
+
return content
|
|
216
|
+
|
|
217
|
+
except UnicodeDecodeError as e:
|
|
218
|
+
logger.warning(f"文件编码错误 (可能是二进制文件): {file_path} - {e}")
|
|
219
|
+
return ''
|
|
220
|
+
except PermissionError as e:
|
|
221
|
+
logger.warning(f"无权限读取文件: {file_path} - {e}")
|
|
222
|
+
return ''
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"读取文件失败: {file_path} - {e}", exc_info=True)
|
|
225
|
+
return ''
|