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 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 };