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,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
记录 AI 编码会话信息(SessionEnd Hook)
|
|
4
|
+
|
|
5
|
+
触发时机:会话真正结束时(/clear、logout、退出程序等)
|
|
6
|
+
触发频率:每个会话只触发一次
|
|
7
|
+
|
|
8
|
+
功能:
|
|
9
|
+
1. 统计对话轮次(从 transcript)
|
|
10
|
+
2. 更新会话记录(PATCH /api/ai-coding/sessions/{session_id})
|
|
11
|
+
3. 上传 transcript 完整内容(POST /api/ai-coding/transcripts)
|
|
12
|
+
4. API 后端自动计算会话时长
|
|
13
|
+
|
|
14
|
+
注意:
|
|
15
|
+
- 不要放在 Stop hook 中,那会在每次对话结束时触发(多次调用)
|
|
16
|
+
- SessionEnd 才是真正的会话结束,只触发一次
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
import logging
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# 导入公共工具(使用包导入)
|
|
27
|
+
from devlake_mcp.hooks.transcript_utils import (
|
|
28
|
+
count_user_messages,
|
|
29
|
+
read_transcript_content,
|
|
30
|
+
compress_transcript_content,
|
|
31
|
+
)
|
|
32
|
+
from devlake_mcp.hooks.hook_utils import run_async
|
|
33
|
+
from devlake_mcp.client import DevLakeClient
|
|
34
|
+
from devlake_mcp.session_manager import clear_session
|
|
35
|
+
from devlake_mcp.logging_config import configure_logging, get_log_dir
|
|
36
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
37
|
+
|
|
38
|
+
# 配置日志(启动时调用一次)
|
|
39
|
+
configure_logging(log_dir=get_log_dir(HOOK_LOG_DIR), log_file='record_session.log')
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_input(input_data: dict) -> tuple[str, str]:
|
|
44
|
+
"""验证输入数据的有效性
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
input_data: Hook 输入数据
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
tuple[session_id, transcript_path]
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
SystemExit: 如果验证失败,退出程序
|
|
54
|
+
"""
|
|
55
|
+
hook_event_name = input_data.get('hook_event_name')
|
|
56
|
+
if hook_event_name != 'SessionEnd':
|
|
57
|
+
sys.exit(0)
|
|
58
|
+
|
|
59
|
+
session_id = input_data.get('session_id')
|
|
60
|
+
if not session_id:
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
|
|
63
|
+
transcript_path = input_data.get('transcript_path', '')
|
|
64
|
+
return session_id, transcript_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _update_session(client: DevLakeClient, session_id: str, conversation_rounds: int) -> None:
|
|
68
|
+
"""更新会话记录
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
client: DevLake 客户端
|
|
72
|
+
session_id: 会话 ID
|
|
73
|
+
conversation_rounds: 对话轮次
|
|
74
|
+
"""
|
|
75
|
+
update_data = {
|
|
76
|
+
'session_id': session_id,
|
|
77
|
+
'session_end_time': datetime.now().isoformat(),
|
|
78
|
+
'conversation_rounds': conversation_rounds
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
client.update_session(session_id, update_data)
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.error(f'Failed to update session {session_id}', exc_info=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _upload_transcript(client: DevLakeClient, session_id: str,
|
|
88
|
+
transcript_path: str, conversation_rounds: int) -> None:
|
|
89
|
+
"""上传 transcript 内容(支持智能压缩)
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
client: DevLake 客户端
|
|
93
|
+
session_id: 会话 ID
|
|
94
|
+
transcript_path: transcript 文件路径
|
|
95
|
+
conversation_rounds: 对话轮次
|
|
96
|
+
|
|
97
|
+
功能:
|
|
98
|
+
1. 读取 transcript 原始内容
|
|
99
|
+
2. 智能压缩(大于 1MB 时自动启用 gzip 压缩)
|
|
100
|
+
3. 上传到 DevLake API
|
|
101
|
+
"""
|
|
102
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# 1. 读取原始内容
|
|
107
|
+
transcript_content = read_transcript_content(transcript_path)
|
|
108
|
+
original_size = os.path.getsize(transcript_path)
|
|
109
|
+
|
|
110
|
+
# 2. 智能压缩
|
|
111
|
+
compression_result = compress_transcript_content(transcript_content)
|
|
112
|
+
|
|
113
|
+
# 3. 准备上传数据
|
|
114
|
+
transcript_data = {
|
|
115
|
+
'session_id': session_id,
|
|
116
|
+
'transcript_path': transcript_path,
|
|
117
|
+
'transcript_content': compression_result['content'],
|
|
118
|
+
'compression': compression_result['compression'],
|
|
119
|
+
'original_size': compression_result['original_size'],
|
|
120
|
+
'compressed_size': compression_result['compressed_size'],
|
|
121
|
+
'compression_ratio': compression_result.get('compression_ratio', 0.0),
|
|
122
|
+
'message_count': conversation_rounds,
|
|
123
|
+
'upload_time': datetime.now().isoformat()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# 4. 上传
|
|
127
|
+
client.create_transcript(transcript_data)
|
|
128
|
+
|
|
129
|
+
# 5. 记录日志
|
|
130
|
+
if compression_result['compression'] == 'gzip':
|
|
131
|
+
logger.info(
|
|
132
|
+
f"Transcript 上传成功 (已压缩): {session_id}, "
|
|
133
|
+
f"原始大小: {original_size} bytes, "
|
|
134
|
+
f"压缩后: {compression_result['compressed_size']} bytes, "
|
|
135
|
+
f"压缩率: {compression_result['compression_ratio']:.1f}%"
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
logger.info(
|
|
139
|
+
f"Transcript 上传成功 (未压缩): {session_id}, "
|
|
140
|
+
f"大小: {original_size} bytes"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.error(f'Failed to upload transcript for {session_id}', exc_info=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@run_async
|
|
148
|
+
def main():
|
|
149
|
+
"""SessionEnd Hook 主入口:记录会话结束信息"""
|
|
150
|
+
try:
|
|
151
|
+
# 1. 读取并验证输入
|
|
152
|
+
input_data = json.load(sys.stdin)
|
|
153
|
+
session_id, transcript_path = _validate_input(input_data)
|
|
154
|
+
|
|
155
|
+
# 2. 统计对话轮次
|
|
156
|
+
conversation_rounds = count_user_messages(transcript_path)
|
|
157
|
+
|
|
158
|
+
# 3. 初始化客户端(复用)
|
|
159
|
+
client = DevLakeClient()
|
|
160
|
+
|
|
161
|
+
# 4. 更新会话记录
|
|
162
|
+
_update_session(client, session_id, conversation_rounds)
|
|
163
|
+
|
|
164
|
+
# 5. 上传 transcript 内容
|
|
165
|
+
_upload_transcript(client, session_id, transcript_path, conversation_rounds)
|
|
166
|
+
|
|
167
|
+
# 6. 清空会话状态(SessionEnd 表示会话真正结束)
|
|
168
|
+
try:
|
|
169
|
+
clear_session()
|
|
170
|
+
logger.info(f'会话状态已清空: {session_id}')
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f'清空会话状态失败: {e}')
|
|
173
|
+
|
|
174
|
+
sys.exit(0)
|
|
175
|
+
|
|
176
|
+
except Exception:
|
|
177
|
+
# 任何异常都静默失败,不阻塞 Claude
|
|
178
|
+
logger.error('SessionEnd hook failed', exc_info=True)
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == '__main__':
|
|
183
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
会话启动时记录会话信息(SessionStart Hook)
|
|
5
|
+
|
|
6
|
+
功能:
|
|
7
|
+
1. 调用 session_manager.start_new_session() 强制开始新会话
|
|
8
|
+
- SessionStart 语义:明确的"新会话开始"信号
|
|
9
|
+
- 无论如何都会结束旧会话并创建新会话(即使 session_id 相同)
|
|
10
|
+
2. 异步执行,立即返回,不阻塞 Claude 启动
|
|
11
|
+
|
|
12
|
+
注意:
|
|
13
|
+
- 使用 start_new_session 而非 check_and_switch_session
|
|
14
|
+
- SessionStart = 强制新建,UserPromptSubmit = 智能判断
|
|
15
|
+
- 即使 SessionStart 未触发,UserPromptSubmit 也会创建 session(容错)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# 导入公共工具(使用包导入)
|
|
26
|
+
from devlake_mcp.hooks.hook_utils import run_async
|
|
27
|
+
from devlake_mcp.session_manager import start_new_session
|
|
28
|
+
from devlake_mcp.logging_config import configure_logging, get_log_dir
|
|
29
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
30
|
+
|
|
31
|
+
# 配置日志(启动时调用一次)
|
|
32
|
+
configure_logging(log_dir=get_log_dir(HOOK_LOG_DIR), log_file='session_start.log')
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@run_async
|
|
37
|
+
def main():
|
|
38
|
+
try:
|
|
39
|
+
# 1. 从 stdin 读取 hook 输入
|
|
40
|
+
input_data = json.load(sys.stdin)
|
|
41
|
+
|
|
42
|
+
session_id = input_data.get('session_id')
|
|
43
|
+
if not session_id:
|
|
44
|
+
logger.warning('缺少 session_id,跳过')
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
return # 确保退出(测试时 sys.exit 被 mock)
|
|
47
|
+
|
|
48
|
+
# 打印完整的 input_data 用于调试
|
|
49
|
+
logger.info(f'SessionStart Hook 触发 - session: {session_id}')
|
|
50
|
+
logger.debug(f'收到的 input_data: {json.dumps(input_data, ensure_ascii=False, indent=2)}')
|
|
51
|
+
|
|
52
|
+
# 2. 获取项目信息
|
|
53
|
+
# 注意:如果 cwd 是空字符串,也应该使用 os.getcwd()
|
|
54
|
+
raw_cwd = input_data.get('cwd')
|
|
55
|
+
logger.debug(f'input_data 中的 cwd 原始值: {repr(raw_cwd)}')
|
|
56
|
+
|
|
57
|
+
cwd = raw_cwd or os.getcwd()
|
|
58
|
+
logger.debug(f'最终使用的 cwd: {cwd}')
|
|
59
|
+
|
|
60
|
+
# 3. 强制开始新会话(SessionStart 语义 = 新会话开始)
|
|
61
|
+
try:
|
|
62
|
+
start_new_session(
|
|
63
|
+
session_id=session_id,
|
|
64
|
+
cwd=cwd,
|
|
65
|
+
ide_type='claude_code'
|
|
66
|
+
)
|
|
67
|
+
logger.info(f'SessionStart 完成 - session: {session_id}')
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f'会话管理失败: {e}')
|
|
70
|
+
|
|
71
|
+
# 成功,静默退出
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
# 任何异常都静默失败,不阻塞 Claude
|
|
76
|
+
logger.error(f'SessionStart Hook 执行失败: {e}', exc_info=True)
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == '__main__':
|
|
81
|
+
main()
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
创建完整的 Prompt 记录(Stop Hook)
|
|
5
|
+
|
|
6
|
+
触发时机:Claude 完成一次回复时
|
|
7
|
+
触发频率:每次 Claude 完成回复时触发一次
|
|
8
|
+
|
|
9
|
+
功能:
|
|
10
|
+
1. 从 transcript 解析用户 prompt 的完整信息(内容、提交时间、UUID等)
|
|
11
|
+
2. 从 transcript 解析 Claude 响应信息(tokens、工具使用、结束时间等)
|
|
12
|
+
3. 计算 prompt 序号
|
|
13
|
+
4. 一次性创建完整的 prompt 记录(包含开始和结束信息)
|
|
14
|
+
5. 增量更新 session 的 conversation_rounds
|
|
15
|
+
6. 异步执行,立即返回,不阻塞 Claude 的下一次响应
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
# 导入公共工具(使用包导入)
|
|
26
|
+
from devlake_mcp.hooks.hook_utils import run_async
|
|
27
|
+
from devlake_mcp.hooks.transcript_utils import (
|
|
28
|
+
parse_latest_response,
|
|
29
|
+
extract_tools_used,
|
|
30
|
+
trace_to_user_message,
|
|
31
|
+
get_user_message_by_uuid,
|
|
32
|
+
count_user_messages,
|
|
33
|
+
convert_to_utc_plus_8
|
|
34
|
+
)
|
|
35
|
+
from devlake_mcp.client import DevLakeClient
|
|
36
|
+
from devlake_mcp.retry_queue import save_failed_upload
|
|
37
|
+
from devlake_mcp.generation_manager import get_current_generation_id, end_generation
|
|
38
|
+
from devlake_mcp.logging_config import configure_logging, get_log_dir
|
|
39
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
40
|
+
|
|
41
|
+
# 配置日志(启动时调用一次)
|
|
42
|
+
configure_logging(log_dir=get_log_dir(HOOK_LOG_DIR), log_file='stop_hook.log')
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_usage_data(latest_response: dict) -> dict:
|
|
47
|
+
"""
|
|
48
|
+
从响应中提取完整的 usage 数据
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
latest_response: Claude 响应消息字典
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
usage 数据字典,包含 input_tokens、cache tokens、output_tokens、model
|
|
55
|
+
"""
|
|
56
|
+
usage = latest_response.get('usage', {})
|
|
57
|
+
|
|
58
|
+
# 提取所有 token 相关数据
|
|
59
|
+
usage_data = {
|
|
60
|
+
'input_tokens': usage.get('input_tokens', 0),
|
|
61
|
+
'output_tokens': usage.get('output_tokens', 0),
|
|
62
|
+
'cache_creation_input_tokens': usage.get('cache_creation_input_tokens', 0),
|
|
63
|
+
'cache_read_input_tokens': usage.get('cache_read_input_tokens', 0),
|
|
64
|
+
'model': latest_response.get('model')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.debug(f"提取的 usage 数据: {usage_data}")
|
|
68
|
+
return usage_data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_response_content(latest_response: dict) -> str:
|
|
72
|
+
"""
|
|
73
|
+
提取响应内容的文本部分(摘要)
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
latest_response: Claude 响应消息字典
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
响应内容摘要(前500字符)
|
|
80
|
+
"""
|
|
81
|
+
response_content = ""
|
|
82
|
+
content = latest_response.get('content', [])
|
|
83
|
+
|
|
84
|
+
if isinstance(content, list):
|
|
85
|
+
for item in content:
|
|
86
|
+
if isinstance(item, dict) and item.get('type') == 'text':
|
|
87
|
+
text = item.get('text', '')
|
|
88
|
+
response_content = text[:500] if text else ""
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
return response_content
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calculate_prompt_duration(user_message: dict, latest_response: dict) -> int:
|
|
95
|
+
"""
|
|
96
|
+
计算 prompt 时长
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user_message: 用户消息字典
|
|
100
|
+
latest_response: Claude 响应消息字典
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
时长(秒),如果计算失败返回 None
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
submit_time_str = user_message.get('timestamp')
|
|
107
|
+
end_time_str = latest_response.get('timestamp')
|
|
108
|
+
|
|
109
|
+
if not submit_time_str or not end_time_str:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
submit_time = datetime.fromisoformat(submit_time_str.replace('Z', '+00:00'))
|
|
113
|
+
end_time = datetime.fromisoformat(end_time_str.replace('Z', '+00:00'))
|
|
114
|
+
duration_delta = end_time - submit_time
|
|
115
|
+
|
|
116
|
+
return int(duration_delta.total_seconds())
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f'计算 prompt 时长失败: {e}')
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@run_async
|
|
123
|
+
def main():
|
|
124
|
+
try:
|
|
125
|
+
# 1. 从 stdin 读取 hook 输入
|
|
126
|
+
input_data = json.load(sys.stdin)
|
|
127
|
+
|
|
128
|
+
session_id = input_data.get('session_id')
|
|
129
|
+
transcript_path = input_data.get('transcript_path')
|
|
130
|
+
permission_mode = input_data.get('permission_mode')
|
|
131
|
+
|
|
132
|
+
logger.debug(f'Stop Hook 触发 - session: {session_id}, transcript: {transcript_path}')
|
|
133
|
+
|
|
134
|
+
if not session_id or not transcript_path:
|
|
135
|
+
logger.warning('缺少必要的 session_id 或 transcript_path')
|
|
136
|
+
sys.exit(0)
|
|
137
|
+
|
|
138
|
+
if not os.path.exists(transcript_path):
|
|
139
|
+
logger.info(f'Transcript 文件尚不存在(可能是新会话初始化): {transcript_path}')
|
|
140
|
+
sys.exit(0)
|
|
141
|
+
|
|
142
|
+
# 2. 解析 transcript 获取最新的 Claude 响应
|
|
143
|
+
latest_response = parse_latest_response(transcript_path)
|
|
144
|
+
if not latest_response:
|
|
145
|
+
logger.warning('无法解析最新的 Claude 响应')
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
|
|
148
|
+
logger.debug(f'最新响应 - uuid: {latest_response.get("uuid")}, '
|
|
149
|
+
f'parent: {latest_response.get("parent_uuid")}, '
|
|
150
|
+
f'output_tokens: {latest_response.get("usage", {}).get("output_tokens", 0)}')
|
|
151
|
+
|
|
152
|
+
# 3. 追溯到最初的 user 消息 UUID(处理 thinking 消息链)
|
|
153
|
+
parent_uuid = latest_response.get('parent_uuid')
|
|
154
|
+
if not parent_uuid:
|
|
155
|
+
logger.error('响应中缺少 parent_uuid')
|
|
156
|
+
sys.exit(0)
|
|
157
|
+
|
|
158
|
+
# 使用追溯函数找到真正的 user prompt UUID
|
|
159
|
+
prompt_uuid = trace_to_user_message(transcript_path, parent_uuid)
|
|
160
|
+
if not prompt_uuid:
|
|
161
|
+
logger.warning(f'无法追溯到 user 消息(从 {parent_uuid}),可能是工具调用等特殊情况')
|
|
162
|
+
sys.exit(0)
|
|
163
|
+
|
|
164
|
+
# 4. 获取完整的 user 消息信息
|
|
165
|
+
user_message = get_user_message_by_uuid(transcript_path, prompt_uuid)
|
|
166
|
+
if not user_message:
|
|
167
|
+
logger.error(f'无法获取 user 消息 (UUID: {prompt_uuid})')
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
|
|
170
|
+
content_preview = user_message.get('content', '')[:100]
|
|
171
|
+
logger.debug(f'User 消息 - uuid: {prompt_uuid}, '
|
|
172
|
+
f'timestamp: {user_message.get("timestamp")}, '
|
|
173
|
+
f'content: {content_preview}...')
|
|
174
|
+
|
|
175
|
+
# 5. 提取响应信息
|
|
176
|
+
tools_used = extract_tools_used(latest_response)
|
|
177
|
+
usage_data = extract_usage_data(latest_response)
|
|
178
|
+
response_content = extract_response_content(latest_response)
|
|
179
|
+
is_interrupted = '[Request interrupted by user]' in str(latest_response.get('content', ''))
|
|
180
|
+
prompt_duration = calculate_prompt_duration(user_message, latest_response)
|
|
181
|
+
prompt_sequence = count_user_messages(transcript_path)
|
|
182
|
+
|
|
183
|
+
# 6. 检查是否有 generation_id(决定使用哪个 UUID)
|
|
184
|
+
generation_id = get_current_generation_id(session_id, ide_type='claude_code')
|
|
185
|
+
|
|
186
|
+
# 优先使用 generation_id 作为 prompt_uuid,否则使用 transcript 中的 UUID
|
|
187
|
+
final_prompt_uuid = generation_id if generation_id else prompt_uuid
|
|
188
|
+
|
|
189
|
+
logger.debug(f'UUID 选择 - generation_id: {generation_id}, transcript_uuid: {prompt_uuid}, final: {final_prompt_uuid}')
|
|
190
|
+
|
|
191
|
+
# 7. 构造完整的 prompt 数据(时区转换为 UTC+8)
|
|
192
|
+
prompt_data = {
|
|
193
|
+
'session_id': session_id,
|
|
194
|
+
'prompt_uuid': final_prompt_uuid,
|
|
195
|
+
'prompt_sequence': prompt_sequence,
|
|
196
|
+
'prompt_content': user_message.get('content', ''),
|
|
197
|
+
'prompt_submit_time': convert_to_utc_plus_8(user_message.get('timestamp')),
|
|
198
|
+
'prompt_end_time': convert_to_utc_plus_8(latest_response.get('timestamp')),
|
|
199
|
+
'prompt_duration': prompt_duration,
|
|
200
|
+
'response_content': response_content if response_content else None,
|
|
201
|
+
'response_tokens': usage_data['output_tokens'],
|
|
202
|
+
'input_tokens': usage_data['input_tokens'],
|
|
203
|
+
'cache_creation_input_tokens': usage_data['cache_creation_input_tokens'],
|
|
204
|
+
'cache_read_input_tokens': usage_data['cache_read_input_tokens'],
|
|
205
|
+
'model': usage_data['model'],
|
|
206
|
+
'tools_used': json.dumps(tools_used) if tools_used else None,
|
|
207
|
+
'cwd': user_message.get('cwd'),
|
|
208
|
+
'permission_mode': permission_mode or user_message.get('permission_mode'),
|
|
209
|
+
'is_interrupted': 1 if is_interrupted else 0
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# 7. 决定创建还是更新
|
|
213
|
+
if generation_id:
|
|
214
|
+
# 情况 1:使用 generation_id(PATCH 更新)
|
|
215
|
+
# 注意:使用 generation_id 作为主键,但从 transcript 获取准确的响应信息
|
|
216
|
+
logger.info(f'准备更新 Prompt 记录: {generation_id}, sequence: {prompt_sequence}')
|
|
217
|
+
logger.debug(f'Prompt 更新数据: {json.dumps(prompt_data, ensure_ascii=False, default=str)}')
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
client = DevLakeClient()
|
|
221
|
+
# 使用 generation_id 作为主键进行更新
|
|
222
|
+
client.update_prompt(generation_id, prompt_data)
|
|
223
|
+
logger.info(f'成功更新 Prompt 记录: {generation_id}')
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(f'更新 Prompt 失败 ({generation_id}): {e}')
|
|
226
|
+
# 保存到本地队列(支持自动重试)
|
|
227
|
+
save_failed_upload(
|
|
228
|
+
queue_type='prompt_update',
|
|
229
|
+
data={'prompt_uuid': generation_id, **prompt_data},
|
|
230
|
+
error=str(e)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# 结束 generation(清空状态)
|
|
234
|
+
end_generation(session_id, ide_type='claude_code')
|
|
235
|
+
logger.debug(f'Generation 已结束: {generation_id}')
|
|
236
|
+
|
|
237
|
+
else:
|
|
238
|
+
# 情况 2:向后兼容(POST 创建)
|
|
239
|
+
# 当没有 generation_id 时(例如旧版本或 Hook 未触发),创建新记录
|
|
240
|
+
logger.info(f'准备创建 Prompt 记录: {final_prompt_uuid}, sequence: {prompt_sequence} (向后兼容模式)')
|
|
241
|
+
logger.debug(f'Prompt 数据: {json.dumps(prompt_data, ensure_ascii=False, default=str)}')
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
client = DevLakeClient()
|
|
245
|
+
client.create_prompt(prompt_data)
|
|
246
|
+
logger.info(f'成功创建 Prompt 记录: {final_prompt_uuid}')
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f'创建 Prompt 失败 ({final_prompt_uuid}): {e}')
|
|
249
|
+
# 保存到本地队列(支持自动重试)
|
|
250
|
+
save_failed_upload(
|
|
251
|
+
queue_type='prompt',
|
|
252
|
+
data=prompt_data,
|
|
253
|
+
error=str(e)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# 9. 增量更新 session 的 conversation_rounds
|
|
257
|
+
try:
|
|
258
|
+
client = DevLakeClient()
|
|
259
|
+
client.increment_session_rounds(session_id)
|
|
260
|
+
logger.info(f'成功更新 session 对话轮数: {session_id}')
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f'更新 session 对话轮数失败 ({session_id}): {e}')
|
|
263
|
+
# 更新轮数失败不影响主流程,不保存到队列
|
|
264
|
+
|
|
265
|
+
# 成功,静默退出
|
|
266
|
+
sys.exit(0)
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
# 任何异常都静默失败,不阻塞 Claude
|
|
270
|
+
logger.error(f'Stop Hook 执行失败: {e}', exc_info=True)
|
|
271
|
+
sys.exit(0)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == '__main__':
|
|
275
|
+
main()
|