remote-claude 0.1.0
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.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/bin/cl +20 -0
- package/bin/cla +20 -0
- package/bin/remote-claude +21 -0
- package/client/client.py +251 -0
- package/lark_client/__init__.py +3 -0
- package/lark_client/capture_output.py +91 -0
- package/lark_client/card_builder.py +1114 -0
- package/lark_client/card_service.py +250 -0
- package/lark_client/config.py +22 -0
- package/lark_client/lark_handler.py +841 -0
- package/lark_client/main.py +306 -0
- package/lark_client/output_cleaner.py +222 -0
- package/lark_client/session_bridge.py +195 -0
- package/lark_client/shared_memory_poller.py +364 -0
- package/lark_client/terminal_buffer.py +215 -0
- package/lark_client/terminal_renderer.py +69 -0
- package/package.json +41 -0
- package/pyproject.toml +14 -0
- package/remote_claude.py +518 -0
- package/scripts/check-env.sh +40 -0
- package/scripts/completion.sh +76 -0
- package/scripts/postinstall.sh +76 -0
- package/server/component_parser.py +1113 -0
- package/server/rich_text_renderer.py +301 -0
- package/server/server.py +801 -0
- package/server/shared_state.py +198 -0
- package/stats/__init__.py +38 -0
- package/stats/collector.py +325 -0
- package/stats/machine.py +47 -0
- package/stats/query.py +151 -0
- package/utils/components.py +165 -0
- package/utils/protocol.py +164 -0
- package/utils/session.py +409 -0
- package/uv.lock +703 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
会话共享状态 (mmap) — 全量快照架构
|
|
3
|
+
|
|
4
|
+
Server 进程将 ClaudeWindow 快照全量写入 mmap 文件,其他进程可通过
|
|
5
|
+
SharedStateReader 随时读取最新快照。
|
|
6
|
+
|
|
7
|
+
设计原则:
|
|
8
|
+
- 每次 write_snapshot() 全量写入序列化后的 ClaudeWindow JSON
|
|
9
|
+
- 读端一次 read() 获取完整快照,无需逐条拼装
|
|
10
|
+
- 写入复杂度 O(blocks 总数),但避免了增量状态污染问题
|
|
11
|
+
|
|
12
|
+
内存布局(200MB mmap 文件):
|
|
13
|
+
[Header 64B] @0 magic(4B) + version(4B) + snapshot_len(4B) + sequence(4B) + 保留
|
|
14
|
+
[Snapshot ~200MB] @64 JSON 序列化的 ClaudeWindow 快照
|
|
15
|
+
|
|
16
|
+
文件路径:/tmp/remote-claude/<name>.mq
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import mmap
|
|
21
|
+
import struct
|
|
22
|
+
from dataclasses import asdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
# ── 布局常量 ──────────────────────────────────────────────────────────────────
|
|
27
|
+
MMAP_SIZE = 200 * 1024 * 1024 # 200MB
|
|
28
|
+
HEADER_SIZE = 64
|
|
29
|
+
|
|
30
|
+
HEADER_OFFSET = 0
|
|
31
|
+
COMPLETED_OFFSET = HEADER_SIZE # @64,快照数据起始
|
|
32
|
+
|
|
33
|
+
MAGIC = b'RCMQ'
|
|
34
|
+
VERSION = 2
|
|
35
|
+
|
|
36
|
+
# Header 内字段偏移
|
|
37
|
+
_H_MAGIC = 0 # 4B
|
|
38
|
+
_H_VERSION = 4 # 4B uint32
|
|
39
|
+
_H_SNAPSHOT_LEN = 8 # 4B uint32 — 快照 JSON 长度
|
|
40
|
+
_H_SEQUENCE = 12 # 4B uint32 — 写入序列号(单调递增)
|
|
41
|
+
# [16:64] 保留
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_mq_path(session_name: str) -> Path:
|
|
45
|
+
"""获取会话的共享状态文件路径"""
|
|
46
|
+
from utils.session import get_mq_path as _get_mq_path
|
|
47
|
+
return _get_mq_path(session_name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _component_to_dict(c) -> dict:
|
|
51
|
+
d = asdict(c)
|
|
52
|
+
d['_type'] = type(c).__name__
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _block_id_from_dict(d: dict) -> str:
|
|
57
|
+
"""从已序列化的 component dict 计算 block_id(与 server._block_id 逻辑一致)"""
|
|
58
|
+
t = d.get('_type', '')
|
|
59
|
+
if t == 'UserInput':
|
|
60
|
+
return f"U:{d.get('text', '')[:80]}"
|
|
61
|
+
elif t == 'OutputBlock':
|
|
62
|
+
content = d.get('content', '')
|
|
63
|
+
first_line = content.split('\n', 1)[0].strip()[:80]
|
|
64
|
+
return f"O:{first_line}"
|
|
65
|
+
elif t == 'OptionBlock':
|
|
66
|
+
sub = d.get('sub_type', 'option')
|
|
67
|
+
if sub == 'permission':
|
|
68
|
+
return f"P:{d.get('question', '')[:80]}"
|
|
69
|
+
return f"Q:{d.get('question', '')[:80]}"
|
|
70
|
+
elif t == 'PermissionBlock':
|
|
71
|
+
# 向后兼容旧数据
|
|
72
|
+
return f"P:{d.get('question', '')[:80]}"
|
|
73
|
+
elif t == 'AgentPanelBlock':
|
|
74
|
+
pt = d.get('panel_type', '')
|
|
75
|
+
if pt == 'detail':
|
|
76
|
+
return f"AP:{d.get('agent_name', '')[:80]}"
|
|
77
|
+
elif pt == 'summary':
|
|
78
|
+
return f"AP:summary:{d.get('agent_count', 0)}"
|
|
79
|
+
return f"AP:list:{d.get('agent_count', 0)}"
|
|
80
|
+
elif t == 'PlanBlock':
|
|
81
|
+
return f"PL:{d.get('title', '')[:80]}"
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Writer ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
class SharedStateWriter:
|
|
88
|
+
"""写端:Server 进程持有,生命周期与 ProxyServer 相同"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, session_name: str):
|
|
91
|
+
self._path = get_mq_path(session_name)
|
|
92
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
self._f = open(self._path, 'w+b')
|
|
95
|
+
self._f.truncate(MMAP_SIZE)
|
|
96
|
+
self._f.flush()
|
|
97
|
+
self._mm = mmap.mmap(self._f.fileno(), MMAP_SIZE)
|
|
98
|
+
|
|
99
|
+
# 写入 header 初始值(magic + version + 全零计数字段)
|
|
100
|
+
self._mm.seek(HEADER_OFFSET)
|
|
101
|
+
self._mm.write(MAGIC)
|
|
102
|
+
self._mm.write(struct.pack('>I', VERSION))
|
|
103
|
+
self._mm.write(b'\x00' * (HEADER_SIZE - 8))
|
|
104
|
+
self._mm.flush()
|
|
105
|
+
|
|
106
|
+
self._sequence = 0
|
|
107
|
+
|
|
108
|
+
def write_snapshot(self, window) -> None:
|
|
109
|
+
"""全量写入 ClaudeWindow 快照"""
|
|
110
|
+
try:
|
|
111
|
+
blocks = []
|
|
112
|
+
for b in window.blocks:
|
|
113
|
+
d = _component_to_dict(b)
|
|
114
|
+
d['block_id'] = _block_id_from_dict(d)
|
|
115
|
+
blocks.append(d)
|
|
116
|
+
# agent_panel 序列化(状态型组件,独立于 blocks)
|
|
117
|
+
agent_panel_dict = None
|
|
118
|
+
if window.agent_panel:
|
|
119
|
+
agent_panel_dict = _component_to_dict(window.agent_panel)
|
|
120
|
+
agent_panel_dict['block_id'] = _block_id_from_dict(agent_panel_dict)
|
|
121
|
+
|
|
122
|
+
# option_block 序列化(状态型组件,独立于 blocks)
|
|
123
|
+
option_block_dict = None
|
|
124
|
+
if window.option_block:
|
|
125
|
+
option_block_dict = _component_to_dict(window.option_block)
|
|
126
|
+
option_block_dict['block_id'] = _block_id_from_dict(option_block_dict)
|
|
127
|
+
|
|
128
|
+
snapshot = {
|
|
129
|
+
"blocks": blocks,
|
|
130
|
+
"status_line": _component_to_dict(window.status_line) if window.status_line else None,
|
|
131
|
+
"bottom_bar": _component_to_dict(window.bottom_bar) if window.bottom_bar else None,
|
|
132
|
+
"agent_panel": agent_panel_dict,
|
|
133
|
+
"option_block": option_block_dict,
|
|
134
|
+
"input_area_text": window.input_area_text,
|
|
135
|
+
"timestamp": window.timestamp,
|
|
136
|
+
"layout_mode": window.layout_mode,
|
|
137
|
+
}
|
|
138
|
+
data = json.dumps(snapshot, ensure_ascii=False).encode('utf-8')
|
|
139
|
+
|
|
140
|
+
# 超出可用空间则跳过(理论上 200MB 足够)
|
|
141
|
+
if COMPLETED_OFFSET + len(data) > MMAP_SIZE:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
self._sequence += 1
|
|
145
|
+
mm = self._mm
|
|
146
|
+
mm.seek(COMPLETED_OFFSET)
|
|
147
|
+
mm.write(data)
|
|
148
|
+
|
|
149
|
+
# 更新 header(snapshot_len + sequence)
|
|
150
|
+
mm.seek(HEADER_OFFSET + _H_SNAPSHOT_LEN)
|
|
151
|
+
mm.write(struct.pack('>II', len(data), self._sequence))
|
|
152
|
+
mm.flush()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def close(self):
|
|
157
|
+
try:
|
|
158
|
+
self._mm.close()
|
|
159
|
+
self._f.close()
|
|
160
|
+
self._path.unlink(missing_ok=True)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Reader ────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
class SharedStateReader:
|
|
168
|
+
"""读端:其他进程持有,按需调用 read() 获取最新快照。
|
|
169
|
+
|
|
170
|
+
使用直接文件 I/O 而非 mmap,避免 macOS 上 mmap ACCESS_READ 不可靠地
|
|
171
|
+
反映跨进程写入更新的问题。每次 read() 打开文件读取后关闭,保证读到最新数据。
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
_EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None}
|
|
175
|
+
|
|
176
|
+
def __init__(self, session_name: str):
|
|
177
|
+
self._path = get_mq_path(session_name)
|
|
178
|
+
|
|
179
|
+
def read(self) -> dict:
|
|
180
|
+
"""读取当前完整快照,返回 dict"""
|
|
181
|
+
try:
|
|
182
|
+
with open(self._path, 'rb') as f:
|
|
183
|
+
header = f.read(16)
|
|
184
|
+
if len(header) < 16 or header[:4] != MAGIC:
|
|
185
|
+
return self._EMPTY
|
|
186
|
+
version = struct.unpack('>I', header[4:8])[0]
|
|
187
|
+
if version < 2:
|
|
188
|
+
return self._EMPTY
|
|
189
|
+
snapshot_len, _sequence = struct.unpack('>II', header[8:16])
|
|
190
|
+
if snapshot_len == 0:
|
|
191
|
+
return self._EMPTY
|
|
192
|
+
f.seek(COMPLETED_OFFSET)
|
|
193
|
+
return json.loads(f.read(snapshot_len).decode('utf-8'))
|
|
194
|
+
except Exception:
|
|
195
|
+
return self._EMPTY
|
|
196
|
+
|
|
197
|
+
def close(self):
|
|
198
|
+
pass # 无持久资源需要释放
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote Claude 使用统计模块
|
|
3
|
+
|
|
4
|
+
全局接口:
|
|
5
|
+
track(category, event, **kwargs) —— 记录事件
|
|
6
|
+
close() —— 关闭前刷新
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
_ENABLED = True
|
|
10
|
+
_MIXPANEL_TOKEN = 'c4d804fc1fe4337132e4da90fdb690c9'
|
|
11
|
+
|
|
12
|
+
from .collector import StatsCollector
|
|
13
|
+
|
|
14
|
+
_collector = StatsCollector(enabled=_ENABLED)
|
|
15
|
+
# 模块加载时自动初始化 Mixpanel(无需外部配置)
|
|
16
|
+
_collector.set_mixpanel_token(_MIXPANEL_TOKEN)
|
|
17
|
+
_collector.report_install()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def track(category: str, event: str, **kwargs) -> None:
|
|
21
|
+
"""记录事件(非阻塞,线程安全,异常不传播)"""
|
|
22
|
+
_collector.track(category, event, **kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_mixpanel(token: str) -> None:
|
|
26
|
+
"""配置 Mixpanel token(保留接口兼容性,通常无需手动调用)"""
|
|
27
|
+
_collector.set_mixpanel_token(token)
|
|
28
|
+
_collector.report_install()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def report_daily(date: str = None) -> None:
|
|
32
|
+
"""手动触发聚合上报(CLI --report 命令使用)"""
|
|
33
|
+
_collector.report_daily(date)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def close() -> None:
|
|
37
|
+
"""关闭前刷新队列"""
|
|
38
|
+
_collector.close()
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StatsCollector:事件收集器
|
|
3
|
+
|
|
4
|
+
- 内存队列(deque)接收 track() 调用(O(1),无 I/O)
|
|
5
|
+
- 每 10 秒或满 50 条时批量写入 SQLite(WAL 模式)
|
|
6
|
+
- 每日聚合上报 Mixpanel(~22 条/天)
|
|
7
|
+
- 所有异常均被捕获,绝不影响主流程
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .machine import get_machine_id, get_machine_info
|
|
19
|
+
|
|
20
|
+
# SQLite 数据库路径(持久化,不受 /tmp 清理影响)
|
|
21
|
+
_DB_DIR = Path.home() / ".local" / "share" / "remote-claude"
|
|
22
|
+
_DB_PATH = _DB_DIR / "stats.db"
|
|
23
|
+
|
|
24
|
+
# 批量写入阈值
|
|
25
|
+
_FLUSH_INTERVAL = 10.0 # 秒
|
|
26
|
+
_FLUSH_BATCH = 50 # 条
|
|
27
|
+
|
|
28
|
+
# 数据保留天数
|
|
29
|
+
_EVENTS_RETENTION = 90 # 天
|
|
30
|
+
_SUMMARY_RETENTION = 365 # 天
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StatsCollector:
|
|
34
|
+
"""事件收集器:内存队列 + SQLite 批量写入 + 定时 Mixpanel 聚合上报"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, enabled: bool = True):
|
|
37
|
+
self._enabled = enabled
|
|
38
|
+
self._queue: deque = deque(maxlen=10000)
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._machine_id = get_machine_id()
|
|
41
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
42
|
+
self._mp = None # Mixpanel 实例,延迟初始化
|
|
43
|
+
self._mp_token: str = ""
|
|
44
|
+
self._last_flush = 0.0
|
|
45
|
+
self._last_report_date: str = ""
|
|
46
|
+
self._is_first_run = False # 是否首次运行(需上报 install 事件)
|
|
47
|
+
|
|
48
|
+
if self._enabled:
|
|
49
|
+
self._init_db()
|
|
50
|
+
self._check_first_run()
|
|
51
|
+
# 后台线程定时 flush
|
|
52
|
+
t = threading.Thread(target=self._flush_loop, daemon=True)
|
|
53
|
+
t.start()
|
|
54
|
+
|
|
55
|
+
# ── 公开接口 ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def track(self, category: str, event: str, session_name: str = '',
|
|
58
|
+
chat_id: str = '', value: int = 1, detail: str = '') -> None:
|
|
59
|
+
"""记录事件到本地(非阻塞,线程安全)"""
|
|
60
|
+
if not self._enabled:
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
now = time.time()
|
|
64
|
+
date = time.strftime('%Y-%m-%d', time.localtime(now))
|
|
65
|
+
# chat_id 脱敏:只保留前 8 位
|
|
66
|
+
safe_chat_id = chat_id[:8] if chat_id else ''
|
|
67
|
+
row = (now, date, category, event, session_name,
|
|
68
|
+
safe_chat_id, value, detail, self._machine_id)
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._queue.append(row)
|
|
71
|
+
should_flush = len(self._queue) >= _FLUSH_BATCH
|
|
72
|
+
if should_flush:
|
|
73
|
+
threading.Thread(target=self._flush, daemon=True).start()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def set_mixpanel_token(self, token: str) -> None:
|
|
78
|
+
"""配置 Mixpanel token(来自 .env)"""
|
|
79
|
+
if not token:
|
|
80
|
+
return
|
|
81
|
+
self._mp_token = token
|
|
82
|
+
try:
|
|
83
|
+
import mixpanel
|
|
84
|
+
self._mp = mixpanel.Mixpanel(token)
|
|
85
|
+
except ImportError:
|
|
86
|
+
pass # mixpanel 未安装时静默跳过
|
|
87
|
+
|
|
88
|
+
def check_and_report(self) -> None:
|
|
89
|
+
"""检查是否需要上报昨日数据(跨天检测,可在后台调用)"""
|
|
90
|
+
if not self._enabled or not self._mp:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
today = time.strftime('%Y-%m-%d')
|
|
94
|
+
if self._last_report_date == today:
|
|
95
|
+
return
|
|
96
|
+
yesterday = time.strftime('%Y-%m-%d',
|
|
97
|
+
time.localtime(time.time() - 86400))
|
|
98
|
+
self.report_daily(yesterday)
|
|
99
|
+
self._last_report_date = today
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def report_daily(self, date: Optional[str] = None) -> None:
|
|
104
|
+
"""聚合指定日期数据并上报 Mixpanel(默认昨天)"""
|
|
105
|
+
if not self._enabled or not self._mp:
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
if date is None:
|
|
109
|
+
date = time.strftime('%Y-%m-%d',
|
|
110
|
+
time.localtime(time.time() - 86400))
|
|
111
|
+
self._flush() # 先把队列里的数据落库
|
|
112
|
+
|
|
113
|
+
conn = self._get_conn()
|
|
114
|
+
rows = conn.execute(
|
|
115
|
+
"SELECT category, event, COUNT(*), SUM(value) "
|
|
116
|
+
"FROM events WHERE date=? GROUP BY category, event",
|
|
117
|
+
(date,)
|
|
118
|
+
).fetchall()
|
|
119
|
+
|
|
120
|
+
if not rows:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
machine_info = get_machine_info()
|
|
124
|
+
for category, event, count, total_value in rows:
|
|
125
|
+
self._mp_track('daily_summary', {
|
|
126
|
+
'category': category,
|
|
127
|
+
'event': event,
|
|
128
|
+
'count': count,
|
|
129
|
+
'total_value': total_value,
|
|
130
|
+
'date': date,
|
|
131
|
+
**machine_info,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
# 上报 heartbeat
|
|
135
|
+
active_sessions = self._count_active_sessions(date, conn)
|
|
136
|
+
self._mp_track('heartbeat', {
|
|
137
|
+
'date': date,
|
|
138
|
+
'active_sessions': active_sessions,
|
|
139
|
+
**machine_info,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
# 写入 daily_summary 表(本地留存)
|
|
143
|
+
for category, event, count, total_value in rows:
|
|
144
|
+
conn.execute(
|
|
145
|
+
"INSERT OR REPLACE INTO daily_summary "
|
|
146
|
+
"(date, category, event, count, total_value) "
|
|
147
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
148
|
+
(date, category, event, count, total_value)
|
|
149
|
+
)
|
|
150
|
+
conn.commit()
|
|
151
|
+
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""关闭前刷新队列"""
|
|
157
|
+
if self._enabled:
|
|
158
|
+
try:
|
|
159
|
+
self._flush()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _init_db(self) -> None:
|
|
166
|
+
"""初始化 SQLite 数据库"""
|
|
167
|
+
try:
|
|
168
|
+
_DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
conn = self._get_conn()
|
|
170
|
+
conn.executescript("""
|
|
171
|
+
PRAGMA journal_mode=WAL;
|
|
172
|
+
PRAGMA synchronous=NORMAL;
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
timestamp REAL NOT NULL,
|
|
177
|
+
date TEXT NOT NULL,
|
|
178
|
+
category TEXT NOT NULL,
|
|
179
|
+
event TEXT NOT NULL,
|
|
180
|
+
session_name TEXT DEFAULT '',
|
|
181
|
+
chat_id TEXT DEFAULT '',
|
|
182
|
+
value INTEGER DEFAULT 1,
|
|
183
|
+
detail TEXT DEFAULT '',
|
|
184
|
+
machine_id TEXT NOT NULL
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
CREATE TABLE IF NOT EXISTS daily_summary (
|
|
188
|
+
date TEXT,
|
|
189
|
+
category TEXT,
|
|
190
|
+
event TEXT,
|
|
191
|
+
count INTEGER,
|
|
192
|
+
total_value INTEGER,
|
|
193
|
+
PRIMARY KEY (date, category, event)
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
197
|
+
key TEXT PRIMARY KEY,
|
|
198
|
+
value TEXT
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_events_date
|
|
202
|
+
ON events(date);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_events_category
|
|
204
|
+
ON events(category, event);
|
|
205
|
+
""")
|
|
206
|
+
conn.commit()
|
|
207
|
+
# 清理过期数据
|
|
208
|
+
self._cleanup_old_data(conn)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def _check_first_run(self) -> None:
|
|
213
|
+
"""检查是否首次运行"""
|
|
214
|
+
try:
|
|
215
|
+
conn = self._get_conn()
|
|
216
|
+
row = conn.execute(
|
|
217
|
+
"SELECT value FROM meta WHERE key='first_run'"
|
|
218
|
+
).fetchone()
|
|
219
|
+
if row is None:
|
|
220
|
+
self._is_first_run = True
|
|
221
|
+
conn.execute(
|
|
222
|
+
"INSERT INTO meta (key, value) VALUES ('first_run', ?)",
|
|
223
|
+
(time.strftime('%Y-%m-%d'),)
|
|
224
|
+
)
|
|
225
|
+
conn.commit()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
230
|
+
"""获取 SQLite 连接(线程本地)"""
|
|
231
|
+
if self._conn is None:
|
|
232
|
+
self._conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
|
|
233
|
+
return self._conn
|
|
234
|
+
|
|
235
|
+
def _flush(self) -> None:
|
|
236
|
+
"""批量写入 SQLite"""
|
|
237
|
+
with self._lock:
|
|
238
|
+
if not self._queue:
|
|
239
|
+
return
|
|
240
|
+
rows = list(self._queue)
|
|
241
|
+
self._queue.clear()
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
conn = self._get_conn()
|
|
245
|
+
conn.executemany(
|
|
246
|
+
"INSERT INTO events "
|
|
247
|
+
"(timestamp, date, category, event, session_name, "
|
|
248
|
+
"chat_id, value, detail, machine_id) "
|
|
249
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
250
|
+
rows
|
|
251
|
+
)
|
|
252
|
+
conn.commit()
|
|
253
|
+
self._last_flush = time.time()
|
|
254
|
+
except Exception:
|
|
255
|
+
# 写失败,把数据放回队列头部(尽力保留)
|
|
256
|
+
with self._lock:
|
|
257
|
+
for row in reversed(rows):
|
|
258
|
+
self._queue.appendleft(row)
|
|
259
|
+
|
|
260
|
+
def _flush_loop(self) -> None:
|
|
261
|
+
"""后台定时 flush 线程"""
|
|
262
|
+
while True:
|
|
263
|
+
try:
|
|
264
|
+
time.sleep(10)
|
|
265
|
+
elapsed = time.time() - self._last_flush
|
|
266
|
+
with self._lock:
|
|
267
|
+
has_data = bool(self._queue)
|
|
268
|
+
if has_data and elapsed >= _FLUSH_INTERVAL:
|
|
269
|
+
self._flush()
|
|
270
|
+
self.check_and_report()
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
def _cleanup_old_data(self, conn: sqlite3.Connection) -> None:
|
|
275
|
+
"""清理过期数据"""
|
|
276
|
+
try:
|
|
277
|
+
conn.execute(
|
|
278
|
+
"DELETE FROM events WHERE date < date('now', ?)",
|
|
279
|
+
(f"-{_EVENTS_RETENTION} days",)
|
|
280
|
+
)
|
|
281
|
+
conn.execute(
|
|
282
|
+
"DELETE FROM daily_summary WHERE date < date('now', ?)",
|
|
283
|
+
(f"-{_SUMMARY_RETENTION} days",)
|
|
284
|
+
)
|
|
285
|
+
conn.commit()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
def _mp_track(self, event_name: str, properties: dict) -> None:
|
|
290
|
+
"""上报单条 Mixpanel 事件"""
|
|
291
|
+
if not self._mp:
|
|
292
|
+
return
|
|
293
|
+
try:
|
|
294
|
+
self._mp.track(self._machine_id, event_name, properties)
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
def _count_active_sessions(self, date: str,
|
|
299
|
+
conn: sqlite3.Connection) -> int:
|
|
300
|
+
"""统计指定日期有 start 事件的会话数"""
|
|
301
|
+
try:
|
|
302
|
+
row = conn.execute(
|
|
303
|
+
"SELECT COUNT(DISTINCT session_name) FROM events "
|
|
304
|
+
"WHERE date=? AND category='session' AND event='start'",
|
|
305
|
+
(date,)
|
|
306
|
+
).fetchone()
|
|
307
|
+
return row[0] if row else 0
|
|
308
|
+
except Exception:
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
def report_install(self) -> None:
|
|
312
|
+
"""首次运行时上报 install 事件和 user profile"""
|
|
313
|
+
if not self._mp or not self._is_first_run:
|
|
314
|
+
return
|
|
315
|
+
try:
|
|
316
|
+
machine_info = get_machine_info()
|
|
317
|
+
import datetime
|
|
318
|
+
self._mp.people_set(self._machine_id, {
|
|
319
|
+
'$name': machine_info['hostname'],
|
|
320
|
+
**machine_info,
|
|
321
|
+
'first_seen': datetime.datetime.now().isoformat(),
|
|
322
|
+
})
|
|
323
|
+
self._mp_track('install', machine_info)
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
package/stats/machine.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
机器标识模块
|
|
3
|
+
|
|
4
|
+
持久化 UUID(~/.remote-claude-id),用于 Mixpanel distinct_id 和跨机器去重。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_ID_FILE = Path.home() / ".remote-claude-id"
|
|
14
|
+
_machine_id: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_machine_id() -> str:
|
|
18
|
+
"""获取(或生成)机器 UUID,持久化到 ~/.remote-claude-id"""
|
|
19
|
+
global _machine_id
|
|
20
|
+
if _machine_id:
|
|
21
|
+
return _machine_id
|
|
22
|
+
|
|
23
|
+
if _ID_FILE.exists():
|
|
24
|
+
try:
|
|
25
|
+
_machine_id = _ID_FILE.read_text().strip()
|
|
26
|
+
if _machine_id:
|
|
27
|
+
return _machine_id
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
# 首次生成
|
|
32
|
+
_machine_id = str(uuid.uuid4())
|
|
33
|
+
try:
|
|
34
|
+
_ID_FILE.write_text(_machine_id)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass # 写失败也继续,只是无法持久化
|
|
37
|
+
|
|
38
|
+
return _machine_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_machine_info() -> dict:
|
|
42
|
+
"""获取机器基础信息(用于 Mixpanel user profile)"""
|
|
43
|
+
return {
|
|
44
|
+
"hostname": platform.node(),
|
|
45
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
46
|
+
"python": platform.python_version(),
|
|
47
|
+
}
|