health-relay-client 1.0.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/README.md +425 -0
- package/ecosystem.config.js +23 -0
- package/package.json +17 -0
- package/src/collectors/agents.js +76 -0
- package/src/collectors/crons.js +35 -0
- package/src/collectors/logs.js +63 -0
- package/src/collectors/models.js +40 -0
- package/src/collectors/openclaw.js +62 -0
- package/src/collectors/skills.js +73 -0
- package/src/collectors/usage.js +58 -0
- package/src/config.js +58 -0
- package/src/health-api.js +125 -0
- package/src/index.js +115 -0
- package/src/relay-client.js +235 -0
- package/src/storage/schema.js +115 -0
- package/src/storage/sqlite.js +224 -0
- package/src/utils/logger.js +66 -0
- package/src/utils/sanitize.js +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# health-relay-client 技术文档
|
|
2
|
+
|
|
3
|
+
> Mac mini 客户端 - 数据收集 + 中继通信
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 项目信息
|
|
8
|
+
|
|
9
|
+
| 属性 | 值 |
|
|
10
|
+
|------|-----|
|
|
11
|
+
| 项目名 | `health-relay-client` |
|
|
12
|
+
| 语言 | Node.js |
|
|
13
|
+
| 依赖 | ws, better-sqlite3 |
|
|
14
|
+
| 部署 | Mac mini |
|
|
15
|
+
| 端口 | 18790 (Health API) |
|
|
16
|
+
| 进程管理 | PM2 |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. 核心职责
|
|
21
|
+
|
|
22
|
+
1. **OpenClaw 数据收集**:定期采集 OpenClaw 各项数据
|
|
23
|
+
2. **Health 数据接收**:通过 HTTP API 接收 iOS 上报的 Health 数据
|
|
24
|
+
3. **数据存储**:SQLite 本地持久化
|
|
25
|
+
4. **中继连接**:WebSocket 连接中继服务器
|
|
26
|
+
5. **实时推送**:有新数据时主动推送给 iOS
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 3. 项目结构
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
health-relay-client/
|
|
34
|
+
├── src/
|
|
35
|
+
│ ├── index.js # 入口
|
|
36
|
+
│ ├── config.js # 配置加载
|
|
37
|
+
│ ├── relay-client.js # 中继客户端
|
|
38
|
+
│ ├── collectors/
|
|
39
|
+
│ │ ├── openclaw.js # OpenClaw 数据收集总入口
|
|
40
|
+
│ │ ├── models.js # 模型列表
|
|
41
|
+
│ │ ├── agents.js # Agent 列表
|
|
42
|
+
│ │ ├── crons.js # Cron 任务
|
|
43
|
+
│ │ ├── skills.js # Skills 列表
|
|
44
|
+
│ │ ├── logs.js # 日志
|
|
45
|
+
│ │ ├── usage.js # Token 使用量
|
|
46
|
+
│ │ └── config.js # 配置文件(脱敏)
|
|
47
|
+
│ ├── health-api.js # Health 数据接收 HTTP API
|
|
48
|
+
│ ├── storage/
|
|
49
|
+
│ │ ├── sqlite.js # SQLite 操作
|
|
50
|
+
│ │ └── schema.js # 表结构
|
|
51
|
+
│ ├── pushers/
|
|
52
|
+
│ │ └── relay-pusher.js # 中继推送
|
|
53
|
+
│ └── utils/
|
|
54
|
+
│ ├── sanitize.js # 脱敏
|
|
55
|
+
│ └── logger.js # 日志
|
|
56
|
+
├── package.json
|
|
57
|
+
├── ecosystem.config.js # PM2 配置
|
|
58
|
+
└── README.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 4. 借鉴 host-connector 的核心设计
|
|
64
|
+
|
|
65
|
+
### 4.1 WebSocket 中继连接
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
const WebSocket = require('ws');
|
|
69
|
+
const os = require('os');
|
|
70
|
+
|
|
71
|
+
class RelayClient {
|
|
72
|
+
constructor() {
|
|
73
|
+
this.ws = null;
|
|
74
|
+
this.reconnectDelay = 1000;
|
|
75
|
+
this.stopped = false;
|
|
76
|
+
this.HOSTNAME = os.hostname();
|
|
77
|
+
this.RELAY_URL = `ws://119.45.24.29:5201/relay/health-${this.HOSTNAME}?secret=${ACCESS_CODE}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
connect() {
|
|
81
|
+
if (this.stopped) return;
|
|
82
|
+
console.log('[relay] Connecting...');
|
|
83
|
+
this.ws = new WebSocket(this.RELAY_URL);
|
|
84
|
+
|
|
85
|
+
this.ws.on('open', () => {
|
|
86
|
+
console.log('[relay] ✅ Connected');
|
|
87
|
+
this.reconnectDelay = 1000;
|
|
88
|
+
this.startHeartbeat();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.ws.on('message', (data) => {
|
|
92
|
+
this.handleMessage(JSON.parse(data.toString()));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.ws.on('close', (code, reason) => {
|
|
96
|
+
console.log(`[relay] Disconnected: ${code}`);
|
|
97
|
+
this.scheduleReconnect();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.ws.on('error', (err) => {
|
|
101
|
+
console.error('[relay] Error:', err.message);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
startHeartbeat() {
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
108
|
+
this.ws.ping();
|
|
109
|
+
}
|
|
110
|
+
}, 30000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
scheduleReconnect() {
|
|
114
|
+
if (this.stopped) return;
|
|
115
|
+
console.log(`[relay] Reconnecting in ${this.reconnectDelay}ms...`);
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
if (!this.stopped) this.connect();
|
|
118
|
+
}, this.reconnectDelay);
|
|
119
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
send(msg) {
|
|
123
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
124
|
+
this.ws.send(JSON.stringify(msg));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4.2 消息处理
|
|
131
|
+
|
|
132
|
+
```javascript
|
|
133
|
+
handleMessage(msg) {
|
|
134
|
+
if (msg.type === 'req') {
|
|
135
|
+
const { method, id, params } = msg;
|
|
136
|
+
console.log(`[relay] ← ${method}`);
|
|
137
|
+
|
|
138
|
+
switch (method) {
|
|
139
|
+
case 'openclaw.status':
|
|
140
|
+
this.collectOpenClawData().then(data => {
|
|
141
|
+
this.send({ type: 'res', id, ok: true, data });
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
case 'health.latest':
|
|
145
|
+
this.getLatestHealthData().then(data => {
|
|
146
|
+
this.send({ type: 'res', id, ok: true, data });
|
|
147
|
+
});
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
this.send({ type: 'res', id, ok: false, error: 'Unknown method' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 5. OpenClaw 数据收集
|
|
159
|
+
|
|
160
|
+
### 5.1 收集方式一览
|
|
161
|
+
|
|
162
|
+
| 数据 | 来源 | 采集频率 |
|
|
163
|
+
|------|------|----------|
|
|
164
|
+
| 模型列表 | `~/.openclaw/openclaw.json` → `models.providers` | 5分钟 |
|
|
165
|
+
| Agent 列表 | 解析配置 + 读 `IDENTITY.md` | 5分钟 |
|
|
166
|
+
| Cron 任务 | `execSync('openclaw cron list --json')` | 1分钟 |
|
|
167
|
+
| Skills | 扫描 `~/.openclaw/skills/*/SKILL.md` | 5分钟 |
|
|
168
|
+
| 日志 | 读 `~/.openclaw/logs/openclaw.log` 末尾100行 | 1分钟 |
|
|
169
|
+
| Token 用量 | 读 `~/.clawcontrol/usage.json` | 5分钟 |
|
|
170
|
+
| 配置文件 | 读 `openclaw.json`(脱敏) | 手动 |
|
|
171
|
+
|
|
172
|
+
### 5.2 配置文件脱敏
|
|
173
|
+
|
|
174
|
+
```javascript
|
|
175
|
+
const SENSITIVE_KEYS = ['apiKey', 'token', 'password', 'secret', 'credential', 'accessCode'];
|
|
176
|
+
|
|
177
|
+
function sanitize(obj) {
|
|
178
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
179
|
+
if (Array.isArray(obj)) return obj.map(sanitize);
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
182
|
+
out[k] = SENSITIVE_KEYS.some(s => k.toLowerCase().includes(s))
|
|
183
|
+
? '[已隐藏]'
|
|
184
|
+
: sanitize(v);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 5.3 Agent 列表采集
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
function collectAgents() {
|
|
194
|
+
const ocConfig = JSON.parse(fs.readFileSync('~/.openclaw/openclaw.json', 'utf8'));
|
|
195
|
+
const agents = ocConfig.agents?.list || [];
|
|
196
|
+
|
|
197
|
+
return agents.map(agent => {
|
|
198
|
+
const workspaceDir = agent.id === 'main'
|
|
199
|
+
? '~/.openclaw/workspace'
|
|
200
|
+
: `~/.openclaw/workspace-${agent.id}`;
|
|
201
|
+
const identityPath = path.join(workspaceDir, 'IDENTITY.md');
|
|
202
|
+
|
|
203
|
+
let name = agent.name || agent.id;
|
|
204
|
+
let emoji = '🤖';
|
|
205
|
+
|
|
206
|
+
if (fs.existsSync(identityPath)) {
|
|
207
|
+
const content = fs.readFileSync(identityPath, 'utf8');
|
|
208
|
+
const nameMatch = content.match(/[-*]\s*Name[::]\s*(.+)/i);
|
|
209
|
+
const emojiMatch = content.match(/[-*]\s*Emoji[::]\s*(.+)/i);
|
|
210
|
+
if (nameMatch) name = nameMatch[1].trim();
|
|
211
|
+
if (emojiMatch) emoji = emojiMatch[1].trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { id: agent.id, name, emoji, model: agent.model };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 6. Health API 接收
|
|
222
|
+
|
|
223
|
+
### 6.1 HTTP 服务
|
|
224
|
+
|
|
225
|
+
```javascript
|
|
226
|
+
const http = require('http');
|
|
227
|
+
|
|
228
|
+
function startHealthAPI() {
|
|
229
|
+
const server = http.createServer((req, res) => {
|
|
230
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
231
|
+
res.setHeader('Content-Type', 'application/json');
|
|
232
|
+
|
|
233
|
+
if (req.method === 'OPTIONS') {
|
|
234
|
+
res.writeHead(204);
|
|
235
|
+
res.end();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (req.method === 'POST' && req.url === '/api/health/sync') {
|
|
240
|
+
let body = '';
|
|
241
|
+
req.on('data', chunk => body += chunk);
|
|
242
|
+
req.on('end', async () => {
|
|
243
|
+
try {
|
|
244
|
+
const data = JSON.parse(body);
|
|
245
|
+
await storage.saveHealthData(data);
|
|
246
|
+
// 可选:推送给已连接的 iOS
|
|
247
|
+
relayClient.pushHealthUpdate(data);
|
|
248
|
+
res.end(JSON.stringify({ ok: true }));
|
|
249
|
+
} catch (e) {
|
|
250
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
} else if (req.method === 'GET' && req.url === '/api/health/latest') {
|
|
254
|
+
const latest = storage.getLatestHealthData();
|
|
255
|
+
res.end(JSON.stringify(latest));
|
|
256
|
+
} else {
|
|
257
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
server.listen(18790, () => {
|
|
262
|
+
console.log('[health-api] Listening on :18790');
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### 6.2 接收的数据格式
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"type": "heartrate",
|
|
272
|
+
"value": 72,
|
|
273
|
+
"timestamp": 1743734400000
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## 7. SQLite 数据存储
|
|
280
|
+
|
|
281
|
+
### 7.1 表结构
|
|
282
|
+
|
|
283
|
+
```sql
|
|
284
|
+
-- Apple Health 数据表
|
|
285
|
+
CREATE TABLE health_heartrate (
|
|
286
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
287
|
+
timestamp INTEGER NOT NULL,
|
|
288
|
+
value REAL NOT NULL,
|
|
289
|
+
type TEXT DEFAULT 'rest'
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
CREATE TABLE health_sleep (
|
|
293
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
294
|
+
start_time INTEGER NOT NULL,
|
|
295
|
+
end_time INTEGER NOT NULL,
|
|
296
|
+
duration INTEGER NOT NULL,
|
|
297
|
+
quality REAL
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
CREATE TABLE health_steps (
|
|
301
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
302
|
+
timestamp INTEGER NOT NULL,
|
|
303
|
+
count INTEGER NOT NULL,
|
|
304
|
+
goal INTEGER
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
CREATE TABLE health_workout (
|
|
308
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
309
|
+
timestamp INTEGER NOT NULL,
|
|
310
|
+
type TEXT,
|
|
311
|
+
duration INTEGER,
|
|
312
|
+
calories INTEGER
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
CREATE TABLE health_weight (
|
|
316
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
317
|
+
timestamp INTEGER NOT NULL,
|
|
318
|
+
weight REAL NOT NULL,
|
|
319
|
+
bmi REAL
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
CREATE TABLE health_oxygen (
|
|
323
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
324
|
+
timestamp INTEGER NOT NULL,
|
|
325
|
+
value REAL NOT NULL
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
CREATE TABLE health_blood_pressure (
|
|
329
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
330
|
+
timestamp INTEGER NOT NULL,
|
|
331
|
+
systolic INTEGER NOT NULL,
|
|
332
|
+
diastolic INTEGER NOT NULL
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
-- OpenClaw 数据表
|
|
336
|
+
CREATE TABLE oc_models (id TEXT PRIMARY KEY, name TEXT, provider TEXT, context_window INTEGER, updated_at INTEGER);
|
|
337
|
+
CREATE TABLE oc_agents (id TEXT PRIMARY KEY, name TEXT, emoji TEXT, model TEXT, updated_at INTEGER);
|
|
338
|
+
CREATE TABLE oc_token_usage (id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT, prompt_tokens INTEGER, completion_tokens INTEGER, total_calls INTEGER, last_used TEXT, timestamp INTEGER);
|
|
339
|
+
CREATE TABLE oc_crons (id TEXT PRIMARY KEY, name TEXT, cron_expr TEXT, next_run TEXT, enabled INTEGER DEFAULT 1, last_run TEXT, updated_at INTEGER);
|
|
340
|
+
CREATE TABLE oc_skills (id TEXT PRIMARY KEY, name TEXT, description TEXT, location TEXT, enabled INTEGER DEFAULT 1, updated_at INTEGER);
|
|
341
|
+
CREATE TABLE oc_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT, message TEXT, timestamp INTEGER);
|
|
342
|
+
|
|
343
|
+
CREATE INDEX idx_health_timestamp ON health_heartrate(timestamp);
|
|
344
|
+
CREATE INDEX idx_oc_logs_timestamp ON oc_logs(timestamp);
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## 8. PM2 进程守护
|
|
350
|
+
|
|
351
|
+
### 8.1 ecosystem.config.js
|
|
352
|
+
|
|
353
|
+
```javascript
|
|
354
|
+
module.exports = {
|
|
355
|
+
apps: [{
|
|
356
|
+
name: 'health-relay-client',
|
|
357
|
+
script: './src/index.js',
|
|
358
|
+
instances: 1,
|
|
359
|
+
autorestart: true,
|
|
360
|
+
watch: false,
|
|
361
|
+
max_memory_restart: '200M',
|
|
362
|
+
env: {
|
|
363
|
+
NODE_ENV: 'production',
|
|
364
|
+
RELAY_HOST: '119.45.24.29',
|
|
365
|
+
RELAY_PORT: '5201',
|
|
366
|
+
ACCESS_CODE: 'health-secret-code-2024',
|
|
367
|
+
HEALTH_API_PORT: '18790'
|
|
368
|
+
}
|
|
369
|
+
}]
|
|
370
|
+
};
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### 8.2 部署命令
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
# 在 Mac mini 上
|
|
377
|
+
cd ~/health-relay-client
|
|
378
|
+
npm install
|
|
379
|
+
pm2 start ecosystem.config.js
|
|
380
|
+
pm2 save
|
|
381
|
+
pm2 startup # 开机自启
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## 9. 配置
|
|
387
|
+
|
|
388
|
+
### 9.1 环境变量或 config.json
|
|
389
|
+
|
|
390
|
+
```json
|
|
391
|
+
{
|
|
392
|
+
"relayHost": "119.45.24.29",
|
|
393
|
+
"relayPort": 5201,
|
|
394
|
+
"accessCode": "health-secret-code-2024",
|
|
395
|
+
"healthApiPort": 18790,
|
|
396
|
+
"macHostname": "LeoMac-mini",
|
|
397
|
+
"openClawDir": "~/.openclaw",
|
|
398
|
+
"usageFile": "~/.clawcontrol/usage.json"
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## 10. 定时任务
|
|
405
|
+
|
|
406
|
+
| 任务 | 间隔 | 说明 |
|
|
407
|
+
|------|------|------|
|
|
408
|
+
| OpenClaw 状态采集 | 1分钟 | 全量数据 |
|
|
409
|
+
| 日志采集 | 1分钟 | 末尾100行 |
|
|
410
|
+
| 模型/Agent/Skills | 5分钟 | 配置解析 |
|
|
411
|
+
| Token 用量 | 5分钟 | usage.json |
|
|
412
|
+
| Health API | 实时 | 接收 iOS 数据 |
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## 11. 依赖
|
|
417
|
+
|
|
418
|
+
```json
|
|
419
|
+
{
|
|
420
|
+
"dependencies": {
|
|
421
|
+
"ws": "^8.20.0",
|
|
422
|
+
"better-sqlite3": "^11.0.0"
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PM2 配置文件
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
apps: [
|
|
7
|
+
{
|
|
8
|
+
name: 'health-relay-client',
|
|
9
|
+
script: './src/index.js',
|
|
10
|
+
instances: 1,
|
|
11
|
+
autorestart: true,
|
|
12
|
+
watch: false,
|
|
13
|
+
max_memory_restart: '200M',
|
|
14
|
+
env: {
|
|
15
|
+
NODE_ENV: 'production',
|
|
16
|
+
RELAY_HOST: 'leonas.asia',
|
|
17
|
+
RELAY_PORT: 5201,
|
|
18
|
+
ACCESS_CODE: 'health-secret-code-2024',
|
|
19
|
+
HEALTH_API_PORT: 18790
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "health-relay-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "HealthClaw Mac Client - OpenClaw Data Collector & Health API",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node src/index.js",
|
|
8
|
+
"dev": "node --watch src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"ws": "^8.20.0",
|
|
12
|
+
"better-sqlite3": "^11.0.0"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Agent 列表收集器
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { logger } = require('../utils/logger');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 获取 Agent 工作目录
|
|
11
|
+
*/
|
|
12
|
+
function getAgentWorkspaceDir(openClawDir, agentId) {
|
|
13
|
+
if (agentId === 'main') {
|
|
14
|
+
return path.join(openClawDir, 'workspace');
|
|
15
|
+
}
|
|
16
|
+
return path.join(openClawDir, `workspace-${agentId}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 从 IDENTITY.md 提取信息
|
|
21
|
+
*/
|
|
22
|
+
function parseIdentity(identityPath) {
|
|
23
|
+
if (!fs.existsSync(identityPath)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const content = fs.readFileSync(identityPath, 'utf8');
|
|
28
|
+
const result = {};
|
|
29
|
+
|
|
30
|
+
const nameMatch = content.match(/^[-*]\s*Name[::]\s*(.+)/mi);
|
|
31
|
+
if (nameMatch) result.name = nameMatch[1].trim();
|
|
32
|
+
|
|
33
|
+
const emojiMatch = content.match(/^[-*]\s*Emoji[::]\s*(.+)/mi);
|
|
34
|
+
if (emojiMatch) result.emoji = emojiMatch[1].trim();
|
|
35
|
+
|
|
36
|
+
const descMatch = content.match(/^[-*]\s*Description[::]\s*(.+)/mi);
|
|
37
|
+
if (descMatch) result.description = descMatch[1].trim();
|
|
38
|
+
|
|
39
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 收集 Agent 列表
|
|
44
|
+
*/
|
|
45
|
+
function collectAgents(openClawDir) {
|
|
46
|
+
const configPath = path.join(openClawDir, 'openclaw.json');
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(configPath)) {
|
|
49
|
+
logger.debug('[collector] openclaw.json not found, skipping agents');
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
55
|
+
const agents = config.agents?.list || [];
|
|
56
|
+
|
|
57
|
+
return agents.map(agent => {
|
|
58
|
+
const workspaceDir = getAgentWorkspaceDir(openClawDir, agent.id);
|
|
59
|
+
const identityPath = path.join(workspaceDir, 'IDENTITY.md');
|
|
60
|
+
const identity = parseIdentity(identityPath);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: agent.id,
|
|
64
|
+
name: identity?.name || agent.name || agent.id,
|
|
65
|
+
emoji: identity?.emoji || '🤖',
|
|
66
|
+
description: identity?.description || null,
|
|
67
|
+
model: agent.model || null
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error('[collector] Failed to collect agents:', err);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { collectAgents };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Cron 任务收集器
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const { logger } = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 收集 Cron 任务列表
|
|
10
|
+
*/
|
|
11
|
+
function collectCrons() {
|
|
12
|
+
try {
|
|
13
|
+
// 尝试使用 openclaw CLI 获取 cron 列表
|
|
14
|
+
const output = execSync('openclaw cron list --json 2>/dev/null', {
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
timeout: 10000
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const crons = JSON.parse(output);
|
|
20
|
+
return Array.isArray(crons) ? crons.map(c => ({
|
|
21
|
+
id: c.id || c.name,
|
|
22
|
+
name: c.name,
|
|
23
|
+
cronExpr: c.cron || c.cronExpr,
|
|
24
|
+
nextRun: c.nextRun || c.next_run,
|
|
25
|
+
enabled: c.enabled !== false,
|
|
26
|
+
lastRun: c.lastRun || c.last_run
|
|
27
|
+
})) : [];
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// CLI 不可用,尝试从配置文件读取
|
|
30
|
+
logger.debug('[collector] openclaw cron CLI not available, skipping');
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { collectCrons };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw 日志收集器
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { logger } = require('../utils/logger');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 收集日志(末尾 100 行)
|
|
11
|
+
*/
|
|
12
|
+
function collectLogs(openClawDir, maxLines = 100) {
|
|
13
|
+
const logFile = path.join(openClawDir, 'logs', 'openclaw.log');
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(logFile)) {
|
|
16
|
+
logger.debug('[collector] openclaw.log not found, skipping');
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const content = fs.readFileSync(logFile, 'utf8');
|
|
22
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
23
|
+
|
|
24
|
+
// 取最后 maxLines 行
|
|
25
|
+
const recentLines = lines.slice(-maxLines);
|
|
26
|
+
|
|
27
|
+
return recentLines.map((line, index) => {
|
|
28
|
+
// 解析日志行格式: [LEVEL] message 或 timestamp LEVEL message
|
|
29
|
+
let level = 'info';
|
|
30
|
+
let message = line;
|
|
31
|
+
let timestamp = Date.now();
|
|
32
|
+
|
|
33
|
+
// 尝试解析 JSON 格式
|
|
34
|
+
if (line.startsWith('{')) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(line);
|
|
37
|
+
level = parsed.level || 'info';
|
|
38
|
+
message = parsed.message || parsed.msg || line;
|
|
39
|
+
timestamp = parsed.timestamp ? new Date(parsed.timestamp).getTime() : Date.now();
|
|
40
|
+
} catch {}
|
|
41
|
+
} else {
|
|
42
|
+
// 尝试解析 [LEVEL] 格式
|
|
43
|
+
const levelMatch = line.match(/^\[(\w+)\]/);
|
|
44
|
+
if (levelMatch) {
|
|
45
|
+
level = levelMatch[1].toLowerCase();
|
|
46
|
+
message = line.substring(levelMatch[0].length).trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id: index,
|
|
52
|
+
level,
|
|
53
|
+
message,
|
|
54
|
+
timestamp
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.error('[collector] Failed to collect logs:', err);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { collectLogs };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw 模型列表收集器
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { logger } = require('../utils/logger');
|
|
8
|
+
const { sanitizeClone } = require('../utils/sanitize');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 收集模型列表
|
|
12
|
+
*/
|
|
13
|
+
function collectModels(openClawDir) {
|
|
14
|
+
const configPath = path.join(openClawDir, 'openclaw.json');
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(configPath)) {
|
|
17
|
+
logger.debug('[collector] openclaw.json not found, skipping models');
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
23
|
+
const models = config.models?.providers || [];
|
|
24
|
+
|
|
25
|
+
// 脱敏处理
|
|
26
|
+
const sanitized = sanitizeClone(models);
|
|
27
|
+
|
|
28
|
+
return sanitized.map(m => ({
|
|
29
|
+
id: m.id || m.name,
|
|
30
|
+
name: m.name,
|
|
31
|
+
provider: m.provider,
|
|
32
|
+
contextWindow: m.context_window || m.contextWindow
|
|
33
|
+
}));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
logger.error('[collector] Failed to collect models:', err);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { collectModels };
|