@trillboards/edge-sdk 0.2.2 → 0.2.4
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 +147 -2
- package/deploy/docker/Dockerfile.cpu +132 -0
- package/deploy/docker/Dockerfile.cuda +134 -0
- package/deploy/docker/Dockerfile.openvino +131 -0
- package/deploy/docker/README.md +358 -0
- package/deploy/helm/README.md +508 -0
- package/deploy/helm/trillboards-edge/Chart.yaml +19 -0
- package/deploy/helm/trillboards-edge/templates/_helpers.tpl +40 -0
- package/deploy/helm/trillboards-edge/templates/daemonset.yaml +120 -0
- package/deploy/helm/trillboards-edge/templates/service.yaml +15 -0
- package/deploy/helm/trillboards-edge/values.yaml +95 -0
- package/deploy/k8s/daemonset.yaml +144 -0
- package/dist/CommandRouter.d.ts +113 -0
- package/dist/CommandRouter.d.ts.map +1 -0
- package/dist/CommandRouter.js +392 -0
- package/dist/CommandRouter.js.map +1 -0
- package/dist/EdgeAgent.d.ts +6 -1
- package/dist/EdgeAgent.d.ts.map +1 -1
- package/dist/EdgeAgent.js +292 -10
- package/dist/EdgeAgent.js.map +1 -1
- package/dist/cli.js +60 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/demo.d.ts +111 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/demo.js +483 -0
- package/dist/demo.js.map +1 -0
- package/dist/diagnose.d.ts +59 -0
- package/dist/diagnose.d.ts.map +1 -0
- package/dist/diagnose.js +651 -0
- package/dist/diagnose.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +19 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +364 -0
- package/dist/init.js.map +1 -0
- package/dist/mcp-server.d.ts +27 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +1264 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/status.d.ts +11 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +343 -0
- package/dist/status.js.map +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* MCP Server for Trillboards Edge SDK
|
|
5
|
+
*
|
|
6
|
+
* A Model Context Protocol (MCP) server that allows AI agents to discover,
|
|
7
|
+
* configure, and diagnose edge devices. Communicates with the Edge Agent's
|
|
8
|
+
* StatusServer via HTTP (localhost:9090) using JSON-RPC 2.0 over stdio.
|
|
9
|
+
*
|
|
10
|
+
* Uses ONLY Node.js built-in modules — zero external dependencies.
|
|
11
|
+
*
|
|
12
|
+
* Claude Desktop config:
|
|
13
|
+
* {
|
|
14
|
+
* "mcpServers": {
|
|
15
|
+
* "trillboards-edge": {
|
|
16
|
+
* "command": "npx",
|
|
17
|
+
* "args": ["-y", "@trillboards/edge-sdk", "mcp"],
|
|
18
|
+
* "env": { "EDGE_STATUS_PORT": "9090" }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Bin entry for package.json (to be added separately):
|
|
24
|
+
* "trillboards-edge-mcp": "./dist/mcp-server.js"
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.startServer = main;
|
|
61
|
+
const http = __importStar(require("http"));
|
|
62
|
+
const https = __importStar(require("https"));
|
|
63
|
+
const readline = __importStar(require("readline"));
|
|
64
|
+
const os = __importStar(require("os"));
|
|
65
|
+
const fs = __importStar(require("fs"));
|
|
66
|
+
const path = __importStar(require("path"));
|
|
67
|
+
const child_process = __importStar(require("child_process"));
|
|
68
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
69
|
+
const STATUS_PORT = parseInt(process.env.EDGE_STATUS_PORT || '9090', 10);
|
|
70
|
+
const STATUS_HOST = process.env.EDGE_STATUS_HOST || 'localhost';
|
|
71
|
+
const SERVER_VERSION = '0.2.3';
|
|
72
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
73
|
+
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
74
|
+
// ─── JSON-RPC Error Codes ──────────────────────────────────────────────────
|
|
75
|
+
const PARSE_ERROR = -32700;
|
|
76
|
+
const INVALID_REQUEST = -32600;
|
|
77
|
+
const METHOD_NOT_FOUND = -32601;
|
|
78
|
+
const INVALID_PARAMS = -32602;
|
|
79
|
+
const INTERNAL_ERROR = -32603;
|
|
80
|
+
// ─── HTTP Helpers ──────────────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* Fetch JSON from the local StatusServer.
|
|
83
|
+
* Uses Node.js built-in http module — no external dependencies.
|
|
84
|
+
*/
|
|
85
|
+
function fetchStatus(statusPath = '/status', port = STATUS_PORT) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const req = http.get({
|
|
88
|
+
hostname: STATUS_HOST,
|
|
89
|
+
port,
|
|
90
|
+
path: statusPath,
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
headers: { Accept: 'application/json' },
|
|
93
|
+
}, (res) => {
|
|
94
|
+
if (res.statusCode !== 200) {
|
|
95
|
+
res.resume(); // drain the response
|
|
96
|
+
reject(new Error(`StatusServer returned HTTP ${res.statusCode} for ${statusPath}`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let data = '';
|
|
100
|
+
res.on('data', (chunk) => {
|
|
101
|
+
data += chunk.toString();
|
|
102
|
+
});
|
|
103
|
+
res.on('end', () => {
|
|
104
|
+
try {
|
|
105
|
+
resolve(JSON.parse(data));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
reject(new Error(`Invalid JSON from StatusServer: ${data.slice(0, 200)}`));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
req.on('error', (err) => {
|
|
113
|
+
reject(new Error(`Cannot connect to Edge Agent StatusServer at ${STATUS_HOST}:${port} — ${err.message}. ` +
|
|
114
|
+
'Is the edge agent running? Start it with: trillboards-edge start --config trillboards.config.json'));
|
|
115
|
+
});
|
|
116
|
+
req.on('timeout', () => {
|
|
117
|
+
req.destroy();
|
|
118
|
+
reject(new Error(`StatusServer request timed out after 5s (${STATUS_HOST}:${port}${statusPath})`));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* POST JSON to the local StatusServer.
|
|
124
|
+
*/
|
|
125
|
+
function postToAgent(agentPath, body, port = STATUS_PORT) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const payload = JSON.stringify(body);
|
|
128
|
+
const req = http.request({
|
|
129
|
+
hostname: STATUS_HOST,
|
|
130
|
+
port,
|
|
131
|
+
path: agentPath,
|
|
132
|
+
method: 'POST',
|
|
133
|
+
timeout: 10000,
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
137
|
+
},
|
|
138
|
+
}, (res) => {
|
|
139
|
+
let data = '';
|
|
140
|
+
res.on('data', (chunk) => {
|
|
141
|
+
data += chunk.toString();
|
|
142
|
+
});
|
|
143
|
+
res.on('end', () => {
|
|
144
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
145
|
+
reject(new Error(`Agent returned HTTP ${res.statusCode}: ${data.slice(0, 500)}`));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
resolve(data ? JSON.parse(data) : { ok: true });
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
req.on('error', (err) => {
|
|
157
|
+
reject(new Error(`Cannot POST to Edge Agent at ${STATUS_HOST}:${port}${agentPath} — ${err.message}`));
|
|
158
|
+
});
|
|
159
|
+
req.on('timeout', () => {
|
|
160
|
+
req.destroy();
|
|
161
|
+
reject(new Error(`POST to ${agentPath} timed out after 10s`));
|
|
162
|
+
});
|
|
163
|
+
req.write(payload);
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// ─── Tool Definitions ──────────────────────────────────────────────────────
|
|
168
|
+
const TOOL_DEFINITIONS = [
|
|
169
|
+
{
|
|
170
|
+
name: 'get-device-status',
|
|
171
|
+
description: 'Returns full device status from the Edge Agent StatusServer. ' +
|
|
172
|
+
'Includes device identity, capability tier, subsystem health, ' +
|
|
173
|
+
'sensing pipeline state, connection status, and uptime.',
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'get-audience-live',
|
|
181
|
+
description: 'Returns live audience sensing data from the edge device. ' +
|
|
182
|
+
'Includes face count, attention scores, VAS (Viewability Attention Score), ' +
|
|
183
|
+
'evidence grade, demographics, dwell time, and ambient conditions.',
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'configure-sensing',
|
|
191
|
+
description: 'Push configuration changes to the running edge agent. ' +
|
|
192
|
+
'Accepts a partial config object that will be deep-merged with the current config. ' +
|
|
193
|
+
'Use this to adjust sensing parameters, camera settings, model execution providers, etc.',
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
config: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
description: 'Partial configuration to merge. Example: {"camera":{"fps":10},"models":{"executionProvider":"openvino"}}',
|
|
200
|
+
additionalProperties: true,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: ['config'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'run-benchmark',
|
|
208
|
+
description: 'Run a quick performance benchmark on the edge device. ' +
|
|
209
|
+
'Measures camera FPS, ML inference latency, memory usage, and dropped frames ' +
|
|
210
|
+
'over the specified duration.',
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
duration: {
|
|
215
|
+
type: 'number',
|
|
216
|
+
description: 'Benchmark duration in seconds. Default: 10. Min: 3, Max: 120.',
|
|
217
|
+
minimum: 3,
|
|
218
|
+
maximum: 120,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'diagnose-hardware',
|
|
225
|
+
description: 'Run system diagnostics on the edge device. ' +
|
|
226
|
+
'Checks camera, audio, GPU/NPU, disk space, network, system temperature, ' +
|
|
227
|
+
'ONNX runtime, and model files. Returns structured pass/warn/fail results.',
|
|
228
|
+
inputSchema: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'list-models',
|
|
235
|
+
description: 'List installed ONNX models on the edge device. ' +
|
|
236
|
+
'Returns model names, file sizes, file paths, and whether each model is loaded.',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'get-buffer-stats',
|
|
244
|
+
description: 'Get signal buffer statistics from the edge agent. ' +
|
|
245
|
+
'Shows buffered signal count, capacity, backend type (sqlite/json), ' +
|
|
246
|
+
'oldest/newest timestamps, and breakdown by evidence grade.',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
// ─── Tool Implementations ──────────────────────────────────────────────────
|
|
254
|
+
async function toolGetDeviceStatus() {
|
|
255
|
+
const status = await fetchStatus('/status');
|
|
256
|
+
return {
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: 'text',
|
|
260
|
+
text: JSON.stringify(status, null, 2),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function toolGetAudienceLive() {
|
|
266
|
+
const status = await fetchStatus('/status');
|
|
267
|
+
// Extract audience-relevant fields from the full status payload.
|
|
268
|
+
// The StatusServer exposes a flat status object; we cherry-pick sensing fields.
|
|
269
|
+
const audience = {
|
|
270
|
+
timestamp: Date.now(),
|
|
271
|
+
// Face detection
|
|
272
|
+
faceCount: status.faceCount ?? status.avgFaceCount ?? 0,
|
|
273
|
+
maxFaceCount: status.maxFaceCount ?? 0,
|
|
274
|
+
// Attention
|
|
275
|
+
attentionScore: status.avgAttention ?? status.attentionScore ?? 0,
|
|
276
|
+
// VAS (Viewability Attention Score)
|
|
277
|
+
vasRaw: status.vasRaw ?? 0,
|
|
278
|
+
vasWeighted: status.vasWeighted ?? 0,
|
|
279
|
+
vasQualityMultiplier: status.vasQualityMultiplier ?? 1,
|
|
280
|
+
// Evidence classification
|
|
281
|
+
observationFamily: status.observationFamily ?? 'EMPTY_SCENE',
|
|
282
|
+
evidenceGrade: status.evidenceGrade ?? 'EMPTY',
|
|
283
|
+
decisionability: status.decisionability ?? 'NON_DECISIONABLE',
|
|
284
|
+
decisionBlockReasons: status.decisionBlockReasons ?? [],
|
|
285
|
+
measurementQuality: status.measurementQuality ?? 'unknown',
|
|
286
|
+
// Demographics (on-device)
|
|
287
|
+
demographics: status.onDeviceDemographics ?? status.demographics ?? null,
|
|
288
|
+
// Dwell time
|
|
289
|
+
avgDwellTimeMs: status.avgDwellTimeMs ?? 0,
|
|
290
|
+
// Ambient
|
|
291
|
+
ambientLux: status.ambientLux ?? null,
|
|
292
|
+
dominantAmbience: status.dominantAmbience ?? null,
|
|
293
|
+
// Audio
|
|
294
|
+
avgNoiseLevel: status.avgNoiseLevel ?? 0,
|
|
295
|
+
speech: status.speech ?? false,
|
|
296
|
+
// Foot traffic
|
|
297
|
+
footTraffic: status.footTraffic ?? 0,
|
|
298
|
+
// Engagement
|
|
299
|
+
emotionalEngagement: status.emotionalEngagement ?? 0,
|
|
300
|
+
adReceptivityScore: status.adReceptivityScore ?? 0,
|
|
301
|
+
// Sensing mode
|
|
302
|
+
sensingMode: status.sensingMode ?? 'unknown',
|
|
303
|
+
// Quality telemetry
|
|
304
|
+
edgeQuality: status.edgeQuality ?? null,
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: 'text',
|
|
310
|
+
text: JSON.stringify(audience, null, 2),
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
async function toolConfigureSensing(params) {
|
|
316
|
+
const config = params.config;
|
|
317
|
+
if (!config || typeof config !== 'object') {
|
|
318
|
+
return {
|
|
319
|
+
isError: true,
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: 'text',
|
|
323
|
+
text: 'Invalid parameters: "config" must be a non-null object with configuration fields to merge.',
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const result = await postToAgent('/config', config);
|
|
329
|
+
return {
|
|
330
|
+
content: [
|
|
331
|
+
{
|
|
332
|
+
type: 'text',
|
|
333
|
+
text: JSON.stringify({
|
|
334
|
+
success: true,
|
|
335
|
+
message: 'Configuration update sent to edge agent.',
|
|
336
|
+
applied: result,
|
|
337
|
+
}, null, 2),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async function toolRunBenchmark(params) {
|
|
343
|
+
let duration = 10;
|
|
344
|
+
if (typeof params.duration === 'number') {
|
|
345
|
+
duration = Math.max(3, Math.min(120, Math.round(params.duration)));
|
|
346
|
+
}
|
|
347
|
+
// Request benchmark from the agent's StatusServer
|
|
348
|
+
const result = await postToAgent('/benchmark', { duration });
|
|
349
|
+
// If the agent returned benchmark data directly, use it.
|
|
350
|
+
// Otherwise, fall back to collecting from /status snapshots.
|
|
351
|
+
if (result &&
|
|
352
|
+
typeof result === 'object' &&
|
|
353
|
+
('fps' in result || 'cameraFps' in result)) {
|
|
354
|
+
return {
|
|
355
|
+
content: [
|
|
356
|
+
{
|
|
357
|
+
type: 'text',
|
|
358
|
+
text: JSON.stringify(result, null, 2),
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// Fallback: poll /status at start and end to compute deltas
|
|
364
|
+
const startStatus = await fetchStatus('/status');
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, duration * 1000));
|
|
366
|
+
const endStatus = await fetchStatus('/status');
|
|
367
|
+
const startQuality = (startStatus.edgeQuality ?? {});
|
|
368
|
+
const endQuality = (endStatus.edgeQuality ?? {});
|
|
369
|
+
const startTotalFrames = asNumber(startQuality.totalFrames, 0);
|
|
370
|
+
const endTotalFrames = asNumber(endQuality.totalFrames, 0);
|
|
371
|
+
const framesDelta = endTotalFrames - startTotalFrames;
|
|
372
|
+
const startDropped = asNumber(startQuality.droppedFrames, 0);
|
|
373
|
+
const endDropped = asNumber(endQuality.droppedFrames, 0);
|
|
374
|
+
const droppedDelta = endDropped - startDropped;
|
|
375
|
+
const benchmark = {
|
|
376
|
+
durationSeconds: duration,
|
|
377
|
+
fps: framesDelta > 0 ? round(framesDelta / duration, 2) : asNumber(endQuality.cameraFps, 0),
|
|
378
|
+
avgInferenceLatencyMs: asNumber(endQuality.inferenceLatencyMs, 0),
|
|
379
|
+
memoryUsageMb: asNumber(endQuality.memoryUsageMb, 0),
|
|
380
|
+
cpuUsagePercent: asNumber(endQuality.cpuUsagePercent, 0),
|
|
381
|
+
droppedFrames: droppedDelta,
|
|
382
|
+
totalFrames: framesDelta,
|
|
383
|
+
faceDetectionConfidence: asNumber(endQuality.faceDetectionConfidence, 0),
|
|
384
|
+
audioClassificationConfidence: asNumber(endQuality.audioClassificationConfidence, 0),
|
|
385
|
+
modelLoadTimeMs: asNumber(endQuality.modelLoadTimeMs, 0),
|
|
386
|
+
timestamp: Date.now(),
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
content: [
|
|
390
|
+
{
|
|
391
|
+
type: 'text',
|
|
392
|
+
text: JSON.stringify(benchmark, null, 2),
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function toolDiagnoseHardware() {
|
|
398
|
+
const checks = [];
|
|
399
|
+
// 1. Edge Agent connectivity
|
|
400
|
+
try {
|
|
401
|
+
const status = await fetchStatus('/status');
|
|
402
|
+
checks.push({
|
|
403
|
+
name: 'edge-agent',
|
|
404
|
+
status: 'pass',
|
|
405
|
+
message: `Edge Agent running on ${STATUS_HOST}:${STATUS_PORT}`,
|
|
406
|
+
details: {
|
|
407
|
+
fingerprint: status.fingerprint ?? null,
|
|
408
|
+
platform: status.platform ?? null,
|
|
409
|
+
uptime: status.uptime ?? null,
|
|
410
|
+
tier: status.tier ?? status.capabilityTier ?? null,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
checks.push({
|
|
416
|
+
name: 'edge-agent',
|
|
417
|
+
status: 'fail',
|
|
418
|
+
message: err instanceof Error ? err.message : 'Cannot reach Edge Agent',
|
|
419
|
+
});
|
|
420
|
+
// If we cannot reach the agent, run local-only diagnostics
|
|
421
|
+
return buildDiagnosticResult(checks, true);
|
|
422
|
+
}
|
|
423
|
+
// 2. System memory
|
|
424
|
+
const totalMemMb = Math.round(os.totalmem() / (1024 * 1024));
|
|
425
|
+
const freeMemMb = Math.round(os.freemem() / (1024 * 1024));
|
|
426
|
+
const memUsedPct = round(((totalMemMb - freeMemMb) / totalMemMb) * 100, 1);
|
|
427
|
+
checks.push({
|
|
428
|
+
name: 'memory',
|
|
429
|
+
status: memUsedPct > 90 ? 'fail' : memUsedPct > 75 ? 'warn' : 'pass',
|
|
430
|
+
message: `${freeMemMb} MB free of ${totalMemMb} MB (${memUsedPct}% used)`,
|
|
431
|
+
details: {
|
|
432
|
+
totalMb: totalMemMb,
|
|
433
|
+
freeMb: freeMemMb,
|
|
434
|
+
usedPercent: memUsedPct,
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
// 3. CPU
|
|
438
|
+
const cpus = os.cpus();
|
|
439
|
+
checks.push({
|
|
440
|
+
name: 'cpu',
|
|
441
|
+
status: cpus.length >= 2 ? 'pass' : 'warn',
|
|
442
|
+
message: `${cpus.length} cores, ${cpus[0]?.model ?? 'unknown'}`,
|
|
443
|
+
details: {
|
|
444
|
+
cores: cpus.length,
|
|
445
|
+
model: cpus[0]?.model ?? 'unknown',
|
|
446
|
+
architecture: os.arch(),
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
// 4. Disk space (check data directory and models directory)
|
|
450
|
+
const dataDir = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards');
|
|
451
|
+
const diskCheck = checkDiskSpace(dataDir);
|
|
452
|
+
checks.push(diskCheck);
|
|
453
|
+
// 5. Camera (V4L2 on Linux, or ask agent)
|
|
454
|
+
const cameraCheck = await checkCamera();
|
|
455
|
+
checks.push(cameraCheck);
|
|
456
|
+
// 6. Audio device
|
|
457
|
+
const audioCheck = checkAudio();
|
|
458
|
+
checks.push(audioCheck);
|
|
459
|
+
// 7. Network connectivity
|
|
460
|
+
const networkCheck = await checkNetwork();
|
|
461
|
+
checks.push(networkCheck);
|
|
462
|
+
// 8. ONNX models
|
|
463
|
+
const modelCheck = checkModels();
|
|
464
|
+
checks.push(modelCheck);
|
|
465
|
+
// 9. System temperature (Linux: /sys/class/thermal)
|
|
466
|
+
const tempCheck = checkTemperature();
|
|
467
|
+
checks.push(tempCheck);
|
|
468
|
+
// 10. Subsystem health from agent
|
|
469
|
+
try {
|
|
470
|
+
const status = await fetchStatus('/status');
|
|
471
|
+
const subsystemHealth = status.subsystemHealth;
|
|
472
|
+
if (subsystemHealth && typeof subsystemHealth === 'object') {
|
|
473
|
+
const failedSubsystems = [];
|
|
474
|
+
for (const [name, info] of Object.entries(subsystemHealth)) {
|
|
475
|
+
const sub = info;
|
|
476
|
+
if (sub.stale === true || sub.status === 'ERROR' || sub.status === 'CRITICAL' || sub.status === 'OFFLINE') {
|
|
477
|
+
failedSubsystems.push(name);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
checks.push({
|
|
481
|
+
name: 'subsystem-health',
|
|
482
|
+
status: failedSubsystems.length > 0 ? 'warn' : 'pass',
|
|
483
|
+
message: failedSubsystems.length > 0
|
|
484
|
+
? `Degraded subsystems: ${failedSubsystems.join(', ')}`
|
|
485
|
+
: 'All subsystems healthy',
|
|
486
|
+
details: subsystemHealth,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Already checked agent connectivity above; skip
|
|
492
|
+
}
|
|
493
|
+
return buildDiagnosticResult(checks, false);
|
|
494
|
+
}
|
|
495
|
+
async function toolListModels() {
|
|
496
|
+
const models = [];
|
|
497
|
+
// Try to get model info from the agent first
|
|
498
|
+
try {
|
|
499
|
+
const status = await fetchStatus('/status');
|
|
500
|
+
if (status.models && Array.isArray(status.models)) {
|
|
501
|
+
return {
|
|
502
|
+
content: [
|
|
503
|
+
{
|
|
504
|
+
type: 'text',
|
|
505
|
+
text: JSON.stringify({ source: 'agent', models: status.models }, null, 2),
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// Agent not available — scan filesystem
|
|
513
|
+
}
|
|
514
|
+
// Scan common model directories
|
|
515
|
+
const modelDirs = [
|
|
516
|
+
'./models',
|
|
517
|
+
path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards', 'models'),
|
|
518
|
+
'/opt/trillboards/models',
|
|
519
|
+
];
|
|
520
|
+
for (const dir of modelDirs) {
|
|
521
|
+
const resolved = path.resolve(dir);
|
|
522
|
+
if (!fs.existsSync(resolved))
|
|
523
|
+
continue;
|
|
524
|
+
try {
|
|
525
|
+
const files = fs.readdirSync(resolved);
|
|
526
|
+
for (const file of files) {
|
|
527
|
+
if (!file.endsWith('.onnx'))
|
|
528
|
+
continue;
|
|
529
|
+
const filePath = path.join(resolved, file);
|
|
530
|
+
try {
|
|
531
|
+
const stat = fs.statSync(filePath);
|
|
532
|
+
models.push({
|
|
533
|
+
name: file.replace('.onnx', ''),
|
|
534
|
+
filename: file,
|
|
535
|
+
path: filePath,
|
|
536
|
+
sizeMb: round(stat.size / (1024 * 1024), 2),
|
|
537
|
+
lastModified: stat.mtime.toISOString(),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
models.push({
|
|
542
|
+
name: file.replace('.onnx', ''),
|
|
543
|
+
filename: file,
|
|
544
|
+
path: filePath,
|
|
545
|
+
sizeMb: 0,
|
|
546
|
+
lastModified: null,
|
|
547
|
+
error: 'Cannot stat file',
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Cannot read directory — skip
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Also check if the agent reports loaded models via status
|
|
557
|
+
let loadedModels = [];
|
|
558
|
+
try {
|
|
559
|
+
const status = await fetchStatus('/status');
|
|
560
|
+
if (status.loadedModels && Array.isArray(status.loadedModels)) {
|
|
561
|
+
loadedModels = status.loadedModels;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// Agent not reachable
|
|
566
|
+
}
|
|
567
|
+
// Annotate models with loaded status
|
|
568
|
+
for (const model of models) {
|
|
569
|
+
model.loaded = loadedModels.some((m) => m === model.name ||
|
|
570
|
+
m === model.filename ||
|
|
571
|
+
m.includes(model.name));
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: 'text',
|
|
577
|
+
text: JSON.stringify({
|
|
578
|
+
source: 'filesystem',
|
|
579
|
+
modelCount: models.length,
|
|
580
|
+
models,
|
|
581
|
+
}, null, 2),
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
async function toolGetBufferStats() {
|
|
587
|
+
// Try to get buffer stats from the agent's StatusServer
|
|
588
|
+
try {
|
|
589
|
+
const status = await fetchStatus('/status');
|
|
590
|
+
const bufferStats = {};
|
|
591
|
+
// The agent exposes buffer stats in the status payload
|
|
592
|
+
if (status.signalBuffer && typeof status.signalBuffer === 'object') {
|
|
593
|
+
Object.assign(bufferStats, status.signalBuffer);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// Extract buffer-related fields from flat status
|
|
597
|
+
bufferStats.bufferedCount = status.bufferedSignals ?? status.signalBufferCount ?? 0;
|
|
598
|
+
bufferStats.capacity = 8640; // SignalBuffer.MAX_CAPACITY
|
|
599
|
+
bufferStats.backend = status.signalBufferBackend ?? 'unknown';
|
|
600
|
+
bufferStats.oldestTimestampMs = status.signalBufferOldestMs ?? null;
|
|
601
|
+
bufferStats.newestTimestampMs = status.signalBufferNewestMs ?? null;
|
|
602
|
+
bufferStats.byEvidenceGrade = status.signalBufferByGrade ?? {};
|
|
603
|
+
}
|
|
604
|
+
// Add derived fields
|
|
605
|
+
const bufferedCount = asNumber(bufferStats.bufferedCount, 0);
|
|
606
|
+
const capacity = asNumber(bufferStats.capacity, 8640);
|
|
607
|
+
bufferStats.usagePercent = round((bufferedCount / capacity) * 100, 1);
|
|
608
|
+
bufferStats.remainingCapacity = capacity - bufferedCount;
|
|
609
|
+
// Add human-readable timestamps
|
|
610
|
+
const oldestMs = bufferStats.oldestTimestampMs;
|
|
611
|
+
const newestMs = bufferStats.newestTimestampMs;
|
|
612
|
+
if (typeof oldestMs === 'number' && oldestMs > 0) {
|
|
613
|
+
bufferStats.oldestSignalAge = humanDuration(Date.now() - oldestMs);
|
|
614
|
+
bufferStats.oldestTimestamp = new Date(oldestMs).toISOString();
|
|
615
|
+
}
|
|
616
|
+
if (typeof newestMs === 'number' && newestMs > 0) {
|
|
617
|
+
bufferStats.newestSignalAge = humanDuration(Date.now() - newestMs);
|
|
618
|
+
bufferStats.newestTimestamp = new Date(newestMs).toISOString();
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
content: [
|
|
622
|
+
{
|
|
623
|
+
type: 'text',
|
|
624
|
+
text: JSON.stringify(bufferStats, null, 2),
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
// If agent is not reachable, try to read the buffer file directly
|
|
631
|
+
return readBufferFromDisk();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function buildDiagnosticResult(checks, agentOffline) {
|
|
635
|
+
const passCount = checks.filter((c) => c.status === 'pass').length;
|
|
636
|
+
const warnCount = checks.filter((c) => c.status === 'warn').length;
|
|
637
|
+
const failCount = checks.filter((c) => c.status === 'fail').length;
|
|
638
|
+
let overallStatus;
|
|
639
|
+
if (failCount > 0) {
|
|
640
|
+
overallStatus = 'critical';
|
|
641
|
+
}
|
|
642
|
+
else if (warnCount > 0) {
|
|
643
|
+
overallStatus = 'degraded';
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
overallStatus = 'healthy';
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
content: [
|
|
650
|
+
{
|
|
651
|
+
type: 'text',
|
|
652
|
+
text: JSON.stringify({
|
|
653
|
+
overallStatus,
|
|
654
|
+
agentReachable: !agentOffline,
|
|
655
|
+
summary: `${passCount} pass, ${warnCount} warn, ${failCount} fail`,
|
|
656
|
+
timestamp: new Date().toISOString(),
|
|
657
|
+
checks,
|
|
658
|
+
}, null, 2),
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function checkDiskSpace(dirPath) {
|
|
664
|
+
try {
|
|
665
|
+
const resolved = path.resolve(dirPath);
|
|
666
|
+
if (!fs.existsSync(resolved)) {
|
|
667
|
+
return {
|
|
668
|
+
name: 'disk',
|
|
669
|
+
status: 'warn',
|
|
670
|
+
message: `Data directory does not exist: ${resolved}`,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// Use df to check disk space on the partition containing the directory.
|
|
674
|
+
// Falls back gracefully if df is not available (Windows).
|
|
675
|
+
try {
|
|
676
|
+
const dfOutput = child_process
|
|
677
|
+
.execSync(`df -k "${resolved}" 2>/dev/null`, {
|
|
678
|
+
encoding: 'utf-8',
|
|
679
|
+
timeout: 5000,
|
|
680
|
+
})
|
|
681
|
+
.trim();
|
|
682
|
+
const lines = dfOutput.split('\n');
|
|
683
|
+
if (lines.length >= 2) {
|
|
684
|
+
// df -k output: Filesystem 1K-blocks Used Available Use% Mounted-on
|
|
685
|
+
const parts = lines[1].split(/\s+/);
|
|
686
|
+
if (parts.length >= 5) {
|
|
687
|
+
const totalKb = parseInt(parts[1], 10);
|
|
688
|
+
const availableKb = parseInt(parts[3], 10);
|
|
689
|
+
const usePct = parseInt(parts[4], 10);
|
|
690
|
+
const totalGb = round(totalKb / (1024 * 1024), 1);
|
|
691
|
+
const availGb = round(availableKb / (1024 * 1024), 1);
|
|
692
|
+
return {
|
|
693
|
+
name: 'disk',
|
|
694
|
+
status: usePct > 95 ? 'fail' : usePct > 85 ? 'warn' : 'pass',
|
|
695
|
+
message: `${availGb} GB free of ${totalGb} GB (${usePct}% used)`,
|
|
696
|
+
details: {
|
|
697
|
+
totalGb,
|
|
698
|
+
availableGb: availGb,
|
|
699
|
+
usedPercent: usePct,
|
|
700
|
+
path: resolved,
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// df not available (Windows) — try statvfs-like approach or just report directory exists
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
name: 'disk',
|
|
711
|
+
status: 'pass',
|
|
712
|
+
message: `Data directory exists: ${resolved}`,
|
|
713
|
+
details: { path: resolved },
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
return {
|
|
718
|
+
name: 'disk',
|
|
719
|
+
status: 'warn',
|
|
720
|
+
message: `Cannot check disk: ${err instanceof Error ? err.message : String(err)}`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function checkCamera() {
|
|
725
|
+
// Linux: check for /dev/video* devices
|
|
726
|
+
if (process.platform === 'linux') {
|
|
727
|
+
try {
|
|
728
|
+
const videoDevices = [];
|
|
729
|
+
if (fs.existsSync('/dev')) {
|
|
730
|
+
const devFiles = fs.readdirSync('/dev');
|
|
731
|
+
for (const f of devFiles) {
|
|
732
|
+
if (f.startsWith('video')) {
|
|
733
|
+
videoDevices.push(`/dev/${f}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (videoDevices.length > 0) {
|
|
738
|
+
return {
|
|
739
|
+
name: 'camera',
|
|
740
|
+
status: 'pass',
|
|
741
|
+
message: `Found ${videoDevices.length} V4L2 device(s): ${videoDevices.join(', ')}`,
|
|
742
|
+
details: { devices: videoDevices },
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
name: 'camera',
|
|
747
|
+
status: 'warn',
|
|
748
|
+
message: 'No V4L2 camera devices found in /dev/video*',
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return {
|
|
753
|
+
name: 'camera',
|
|
754
|
+
status: 'warn',
|
|
755
|
+
message: 'Cannot enumerate camera devices',
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Windows/macOS: check via agent status
|
|
760
|
+
try {
|
|
761
|
+
const status = await fetchStatus('/status');
|
|
762
|
+
const subsystemHealth = status.subsystemHealth;
|
|
763
|
+
if (subsystemHealth) {
|
|
764
|
+
const cameraHealth = subsystemHealth.CAMERA;
|
|
765
|
+
if (cameraHealth) {
|
|
766
|
+
const cameraStatus = cameraHealth.status;
|
|
767
|
+
return {
|
|
768
|
+
name: 'camera',
|
|
769
|
+
status: cameraStatus === 'ACTIVE' ? 'pass' : cameraStatus === 'ERROR' ? 'fail' : 'warn',
|
|
770
|
+
message: `Camera status: ${cameraStatus}`,
|
|
771
|
+
details: cameraHealth,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// Agent not reachable
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
name: 'camera',
|
|
781
|
+
status: 'warn',
|
|
782
|
+
message: 'Cannot determine camera status (agent not reachable or not Linux)',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function checkAudio() {
|
|
786
|
+
// Linux: check for PulseAudio or ALSA
|
|
787
|
+
if (process.platform === 'linux') {
|
|
788
|
+
try {
|
|
789
|
+
// Check if PulseAudio is running
|
|
790
|
+
try {
|
|
791
|
+
child_process.execSync('pactl info 2>/dev/null', {
|
|
792
|
+
encoding: 'utf-8',
|
|
793
|
+
timeout: 3000,
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
name: 'audio',
|
|
797
|
+
status: 'pass',
|
|
798
|
+
message: 'PulseAudio is running',
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// PulseAudio not running, check ALSA
|
|
803
|
+
}
|
|
804
|
+
// Check ALSA devices
|
|
805
|
+
if (fs.existsSync('/proc/asound/cards')) {
|
|
806
|
+
const cards = fs.readFileSync('/proc/asound/cards', 'utf-8').trim();
|
|
807
|
+
if (cards.length > 0 && !cards.includes('--- no soundcards ---')) {
|
|
808
|
+
return {
|
|
809
|
+
name: 'audio',
|
|
810
|
+
status: 'pass',
|
|
811
|
+
message: 'ALSA audio devices detected',
|
|
812
|
+
details: { cards: cards.split('\n').slice(0, 4) },
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
name: 'audio',
|
|
818
|
+
status: 'warn',
|
|
819
|
+
message: 'No audio devices detected',
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
return {
|
|
824
|
+
name: 'audio',
|
|
825
|
+
status: 'warn',
|
|
826
|
+
message: 'Cannot check audio subsystem',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// Non-Linux: basic check
|
|
831
|
+
return {
|
|
832
|
+
name: 'audio',
|
|
833
|
+
status: 'pass',
|
|
834
|
+
message: `Audio check skipped on ${process.platform} — relies on agent reporting`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
async function checkNetwork() {
|
|
838
|
+
return new Promise((resolve) => {
|
|
839
|
+
const req = https.get({
|
|
840
|
+
hostname: 'api.trillboards.com',
|
|
841
|
+
port: 443,
|
|
842
|
+
path: '/health/live',
|
|
843
|
+
timeout: 5000,
|
|
844
|
+
}, (res) => {
|
|
845
|
+
res.resume(); // drain
|
|
846
|
+
resolve({
|
|
847
|
+
name: 'network',
|
|
848
|
+
status: res.statusCode === 200 ? 'pass' : 'warn',
|
|
849
|
+
message: `Trillboards API reachable (HTTPS ${res.statusCode})`,
|
|
850
|
+
details: { statusCode: res.statusCode },
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
req.on('error', (err) => {
|
|
854
|
+
resolve({
|
|
855
|
+
name: 'network',
|
|
856
|
+
status: 'warn',
|
|
857
|
+
message: `Cannot reach Trillboards API: ${err.message}`,
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
req.on('timeout', () => {
|
|
861
|
+
req.destroy();
|
|
862
|
+
resolve({
|
|
863
|
+
name: 'network',
|
|
864
|
+
status: 'warn',
|
|
865
|
+
message: 'Trillboards API connection timed out (5s)',
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function checkModels() {
|
|
871
|
+
const modelDirs = [
|
|
872
|
+
'./models',
|
|
873
|
+
path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards', 'models'),
|
|
874
|
+
'/opt/trillboards/models',
|
|
875
|
+
];
|
|
876
|
+
const expectedModels = ['blazeface.onnx', 'yamnet.onnx'];
|
|
877
|
+
const found = [];
|
|
878
|
+
const missing = [];
|
|
879
|
+
let searchedDir = null;
|
|
880
|
+
for (const dir of modelDirs) {
|
|
881
|
+
const resolved = path.resolve(dir);
|
|
882
|
+
if (!fs.existsSync(resolved))
|
|
883
|
+
continue;
|
|
884
|
+
searchedDir = resolved;
|
|
885
|
+
for (const model of expectedModels) {
|
|
886
|
+
const modelPath = path.join(resolved, model);
|
|
887
|
+
if (fs.existsSync(modelPath)) {
|
|
888
|
+
found.push(model);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (found.length > 0)
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
for (const model of expectedModels) {
|
|
895
|
+
if (!found.includes(model)) {
|
|
896
|
+
missing.push(model);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (found.length === expectedModels.length) {
|
|
900
|
+
return {
|
|
901
|
+
name: 'models',
|
|
902
|
+
status: 'pass',
|
|
903
|
+
message: `All ${found.length} required models found`,
|
|
904
|
+
details: { found, directory: searchedDir },
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (found.length > 0) {
|
|
908
|
+
return {
|
|
909
|
+
name: 'models',
|
|
910
|
+
status: 'warn',
|
|
911
|
+
message: `Found ${found.length}/${expectedModels.length} models, missing: ${missing.join(', ')}`,
|
|
912
|
+
details: { found, missing, directory: searchedDir },
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
name: 'models',
|
|
917
|
+
status: 'warn',
|
|
918
|
+
message: `No ONNX models found. Run: trillboards-edge download-models`,
|
|
919
|
+
details: { searchedDirs: modelDirs.map((d) => path.resolve(d)) },
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function checkTemperature() {
|
|
923
|
+
if (process.platform !== 'linux') {
|
|
924
|
+
return {
|
|
925
|
+
name: 'temperature',
|
|
926
|
+
status: 'pass',
|
|
927
|
+
message: `Temperature check skipped on ${process.platform}`,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
const thermalZones = [
|
|
932
|
+
'/sys/class/thermal/thermal_zone0/temp',
|
|
933
|
+
'/sys/class/thermal/thermal_zone1/temp',
|
|
934
|
+
];
|
|
935
|
+
for (const zone of thermalZones) {
|
|
936
|
+
if (fs.existsSync(zone)) {
|
|
937
|
+
const raw = fs.readFileSync(zone, 'utf-8').trim();
|
|
938
|
+
const tempC = parseInt(raw, 10) / 1000;
|
|
939
|
+
return {
|
|
940
|
+
name: 'temperature',
|
|
941
|
+
status: tempC > 85 ? 'fail' : tempC > 70 ? 'warn' : 'pass',
|
|
942
|
+
message: `CPU temperature: ${tempC.toFixed(1)}C`,
|
|
943
|
+
details: { temperatureC: round(tempC, 1), source: zone },
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
name: 'temperature',
|
|
949
|
+
status: 'pass',
|
|
950
|
+
message: 'No thermal zones found — temperature monitoring unavailable',
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
return {
|
|
955
|
+
name: 'temperature',
|
|
956
|
+
status: 'pass',
|
|
957
|
+
message: 'Cannot read thermal sensor',
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function readBufferFromDisk() {
|
|
962
|
+
// Try to read the signal buffer file directly from the data directory.
|
|
963
|
+
// This is a last resort when the agent is not running.
|
|
964
|
+
const dataDir = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards');
|
|
965
|
+
const sqlitePath = path.join(dataDir, 'signal_buffer.db');
|
|
966
|
+
const jsonPath = path.join(dataDir, 'signal_buffer.json');
|
|
967
|
+
if (fs.existsSync(jsonPath)) {
|
|
968
|
+
try {
|
|
969
|
+
const raw = fs.readFileSync(jsonPath, 'utf-8');
|
|
970
|
+
const data = JSON.parse(raw);
|
|
971
|
+
const signals = Array.isArray(data.signals) ? data.signals : [];
|
|
972
|
+
const timestamps = signals
|
|
973
|
+
.map((s) => s.timestamp)
|
|
974
|
+
.filter((t) => typeof t === 'number');
|
|
975
|
+
return {
|
|
976
|
+
content: [
|
|
977
|
+
{
|
|
978
|
+
type: 'text',
|
|
979
|
+
text: JSON.stringify({
|
|
980
|
+
source: 'disk-json',
|
|
981
|
+
agentRunning: false,
|
|
982
|
+
bufferedCount: signals.length,
|
|
983
|
+
capacity: 8640,
|
|
984
|
+
usagePercent: round((signals.length / 8640) * 100, 1),
|
|
985
|
+
oldestTimestampMs: timestamps.length > 0 ? Math.min(...timestamps) : null,
|
|
986
|
+
newestTimestampMs: timestamps.length > 0 ? Math.max(...timestamps) : null,
|
|
987
|
+
filePath: jsonPath,
|
|
988
|
+
}, null, 2),
|
|
989
|
+
},
|
|
990
|
+
],
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// Corrupted JSON file
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (fs.existsSync(sqlitePath)) {
|
|
998
|
+
return {
|
|
999
|
+
content: [
|
|
1000
|
+
{
|
|
1001
|
+
type: 'text',
|
|
1002
|
+
text: JSON.stringify({
|
|
1003
|
+
source: 'disk-sqlite',
|
|
1004
|
+
agentRunning: false,
|
|
1005
|
+
message: 'SQLite buffer exists but cannot be read without the agent running. ' +
|
|
1006
|
+
'Start the edge agent to get live buffer statistics.',
|
|
1007
|
+
filePath: sqlitePath,
|
|
1008
|
+
fileSizeMb: round(fs.statSync(sqlitePath).size / (1024 * 1024), 2),
|
|
1009
|
+
}, null, 2),
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
content: [
|
|
1016
|
+
{
|
|
1017
|
+
type: 'text',
|
|
1018
|
+
text: JSON.stringify({
|
|
1019
|
+
source: 'none',
|
|
1020
|
+
agentRunning: false,
|
|
1021
|
+
bufferedCount: 0,
|
|
1022
|
+
message: 'No signal buffer found. The edge agent may not have been started yet.',
|
|
1023
|
+
}, null, 2),
|
|
1024
|
+
},
|
|
1025
|
+
],
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
// ─── Utility Functions ─────────────────────────────────────────────────────
|
|
1029
|
+
function asNumber(value, fallback) {
|
|
1030
|
+
if (typeof value === 'number' && !Number.isNaN(value))
|
|
1031
|
+
return value;
|
|
1032
|
+
if (typeof value === 'string') {
|
|
1033
|
+
const parsed = parseFloat(value);
|
|
1034
|
+
if (!Number.isNaN(parsed))
|
|
1035
|
+
return parsed;
|
|
1036
|
+
}
|
|
1037
|
+
return fallback;
|
|
1038
|
+
}
|
|
1039
|
+
function round(value, decimals) {
|
|
1040
|
+
const factor = Math.pow(10, decimals);
|
|
1041
|
+
return Math.round(value * factor) / factor;
|
|
1042
|
+
}
|
|
1043
|
+
function humanDuration(ms) {
|
|
1044
|
+
if (ms < 0)
|
|
1045
|
+
return '0s';
|
|
1046
|
+
if (ms < 1000)
|
|
1047
|
+
return `${ms}ms`;
|
|
1048
|
+
if (ms < 60000)
|
|
1049
|
+
return `${round(ms / 1000, 1)}s`;
|
|
1050
|
+
if (ms < 3600000)
|
|
1051
|
+
return `${round(ms / 60000, 1)}m`;
|
|
1052
|
+
if (ms < 86400000)
|
|
1053
|
+
return `${round(ms / 3600000, 1)}h`;
|
|
1054
|
+
return `${round(ms / 86400000, 1)}d`;
|
|
1055
|
+
}
|
|
1056
|
+
// ─── MCP Method Handlers ───────────────────────────────────────────────────
|
|
1057
|
+
function handleInitialize(id) {
|
|
1058
|
+
return {
|
|
1059
|
+
jsonrpc: '2.0',
|
|
1060
|
+
id,
|
|
1061
|
+
result: {
|
|
1062
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1063
|
+
capabilities: {
|
|
1064
|
+
tools: {},
|
|
1065
|
+
},
|
|
1066
|
+
serverInfo: {
|
|
1067
|
+
name: 'trillboards-edge-mcp',
|
|
1068
|
+
version: SERVER_VERSION,
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function handleToolsList(id) {
|
|
1074
|
+
return {
|
|
1075
|
+
jsonrpc: '2.0',
|
|
1076
|
+
id,
|
|
1077
|
+
result: {
|
|
1078
|
+
tools: TOOL_DEFINITIONS,
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
async function handleToolsCall(id, params) {
|
|
1083
|
+
if (!params || typeof params !== 'object') {
|
|
1084
|
+
return makeErrorResponse(id, INVALID_PARAMS, 'Missing params for tools/call');
|
|
1085
|
+
}
|
|
1086
|
+
const callParams = params;
|
|
1087
|
+
const toolName = callParams.name;
|
|
1088
|
+
const toolArgs = callParams.arguments ?? {};
|
|
1089
|
+
if (typeof toolName !== 'string') {
|
|
1090
|
+
return makeErrorResponse(id, INVALID_PARAMS, 'Missing "name" in tools/call params');
|
|
1091
|
+
}
|
|
1092
|
+
const toolDef = TOOL_DEFINITIONS.find((t) => t.name === toolName);
|
|
1093
|
+
if (!toolDef) {
|
|
1094
|
+
return makeErrorResponse(id, INVALID_PARAMS, `Unknown tool: "${toolName}". Available tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(', ')}`);
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
let result;
|
|
1098
|
+
switch (toolName) {
|
|
1099
|
+
case 'get-device-status':
|
|
1100
|
+
result = await toolGetDeviceStatus();
|
|
1101
|
+
break;
|
|
1102
|
+
case 'get-audience-live':
|
|
1103
|
+
result = await toolGetAudienceLive();
|
|
1104
|
+
break;
|
|
1105
|
+
case 'configure-sensing':
|
|
1106
|
+
result = await toolConfigureSensing(toolArgs);
|
|
1107
|
+
break;
|
|
1108
|
+
case 'run-benchmark':
|
|
1109
|
+
result = await toolRunBenchmark(toolArgs);
|
|
1110
|
+
break;
|
|
1111
|
+
case 'diagnose-hardware':
|
|
1112
|
+
result = await toolDiagnoseHardware();
|
|
1113
|
+
break;
|
|
1114
|
+
case 'list-models':
|
|
1115
|
+
result = await toolListModels();
|
|
1116
|
+
break;
|
|
1117
|
+
case 'get-buffer-stats':
|
|
1118
|
+
result = await toolGetBufferStats();
|
|
1119
|
+
break;
|
|
1120
|
+
default:
|
|
1121
|
+
return makeErrorResponse(id, METHOD_NOT_FOUND, `Tool not implemented: ${toolName}`);
|
|
1122
|
+
}
|
|
1123
|
+
return {
|
|
1124
|
+
jsonrpc: '2.0',
|
|
1125
|
+
id,
|
|
1126
|
+
result,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1131
|
+
return {
|
|
1132
|
+
jsonrpc: '2.0',
|
|
1133
|
+
id,
|
|
1134
|
+
result: {
|
|
1135
|
+
content: [
|
|
1136
|
+
{
|
|
1137
|
+
type: 'text',
|
|
1138
|
+
text: `Error executing tool "${toolName}": ${message}`,
|
|
1139
|
+
},
|
|
1140
|
+
],
|
|
1141
|
+
isError: true,
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
// ─── JSON-RPC Response Helpers ─────────────────────────────────────────────
|
|
1147
|
+
function makeErrorResponse(id, code, message, data) {
|
|
1148
|
+
return {
|
|
1149
|
+
jsonrpc: '2.0',
|
|
1150
|
+
id,
|
|
1151
|
+
error: { code, message, ...(data !== undefined ? { data } : {}) },
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
// ─── Message Router ────────────────────────────────────────────────────────
|
|
1155
|
+
async function handleMessage(request) {
|
|
1156
|
+
const { id, method, params } = request;
|
|
1157
|
+
// Notifications (no id) do not require a response per JSON-RPC spec.
|
|
1158
|
+
// MCP uses notifications for things like initialized, cancelled, etc.
|
|
1159
|
+
if (id === undefined || id === null) {
|
|
1160
|
+
// Handle known notifications silently
|
|
1161
|
+
switch (method) {
|
|
1162
|
+
case 'notifications/initialized':
|
|
1163
|
+
case 'notifications/cancelled':
|
|
1164
|
+
case 'initialized':
|
|
1165
|
+
process.stderr.write(`[trillboards-edge-mcp] Notification: ${method}\n`);
|
|
1166
|
+
return null; // No response for notifications
|
|
1167
|
+
default:
|
|
1168
|
+
process.stderr.write(`[trillboards-edge-mcp] Unknown notification: ${method}\n`);
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
switch (method) {
|
|
1173
|
+
case 'initialize':
|
|
1174
|
+
return handleInitialize(id);
|
|
1175
|
+
case 'tools/list':
|
|
1176
|
+
return handleToolsList(id);
|
|
1177
|
+
case 'tools/call':
|
|
1178
|
+
return await handleToolsCall(id, params);
|
|
1179
|
+
case 'ping':
|
|
1180
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
1181
|
+
default:
|
|
1182
|
+
return makeErrorResponse(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
// ─── Main — stdio JSON-RPC transport ───────────────────────────────────────
|
|
1186
|
+
async function main() {
|
|
1187
|
+
process.stderr.write(`[trillboards-edge-mcp] MCP server v${SERVER_VERSION} starting (StatusServer: ${STATUS_HOST}:${STATUS_PORT})\n`);
|
|
1188
|
+
const rl = readline.createInterface({
|
|
1189
|
+
input: process.stdin,
|
|
1190
|
+
terminal: false,
|
|
1191
|
+
});
|
|
1192
|
+
let buffer = '';
|
|
1193
|
+
rl.on('line', async (line) => {
|
|
1194
|
+
buffer += line;
|
|
1195
|
+
// Guard against unbounded buffer growth
|
|
1196
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
1197
|
+
process.stderr.write('[trillboards-edge-mcp] Buffer exceeded 10 MB -- resetting\n');
|
|
1198
|
+
buffer = '';
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
// Try to parse the accumulated buffer as JSON.
|
|
1202
|
+
// MCP clients send one JSON-RPC message per line.
|
|
1203
|
+
let request;
|
|
1204
|
+
try {
|
|
1205
|
+
request = JSON.parse(buffer);
|
|
1206
|
+
buffer = '';
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
// Incomplete JSON — keep buffering across lines
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
// Validate JSON-RPC 2.0 structure
|
|
1213
|
+
if (request.jsonrpc !== '2.0') {
|
|
1214
|
+
const errorResp = makeErrorResponse(request.id ?? null, INVALID_REQUEST, 'Expected jsonrpc: "2.0"');
|
|
1215
|
+
process.stdout.write(JSON.stringify(errorResp) + '\n');
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (typeof request.method !== 'string') {
|
|
1219
|
+
const errorResp = makeErrorResponse(request.id ?? null, INVALID_REQUEST, 'Missing "method" field');
|
|
1220
|
+
process.stdout.write(JSON.stringify(errorResp) + '\n');
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
const response = await handleMessage(request);
|
|
1225
|
+
// Notifications return null — do not send a response
|
|
1226
|
+
if (response !== null) {
|
|
1227
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
// Catch-all: never crash the server
|
|
1232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1233
|
+
process.stderr.write(`[trillboards-edge-mcp] Unhandled error: ${message}\n`);
|
|
1234
|
+
const errorResp = makeErrorResponse(request.id ?? null, INTERNAL_ERROR, `Internal server error: ${message}`);
|
|
1235
|
+
process.stdout.write(JSON.stringify(errorResp) + '\n');
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
rl.on('close', () => {
|
|
1239
|
+
process.stderr.write('[trillboards-edge-mcp] stdin closed, exiting\n');
|
|
1240
|
+
process.exit(0);
|
|
1241
|
+
});
|
|
1242
|
+
// Handle process signals gracefully
|
|
1243
|
+
process.on('SIGINT', () => {
|
|
1244
|
+
process.stderr.write('[trillboards-edge-mcp] SIGINT received, exiting\n');
|
|
1245
|
+
process.exit(0);
|
|
1246
|
+
});
|
|
1247
|
+
process.on('SIGTERM', () => {
|
|
1248
|
+
process.stderr.write('[trillboards-edge-mcp] SIGTERM received, exiting\n');
|
|
1249
|
+
process.exit(0);
|
|
1250
|
+
});
|
|
1251
|
+
// Prevent unhandled promise rejections from crashing the process
|
|
1252
|
+
process.on('unhandledRejection', (reason) => {
|
|
1253
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
1254
|
+
process.stderr.write(`[trillboards-edge-mcp] Unhandled rejection: ${message}\n`);
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
// Auto-start when run directly
|
|
1258
|
+
if (require.main === module) {
|
|
1259
|
+
main().catch((err) => {
|
|
1260
|
+
process.stderr.write(`[trillboards-edge-mcp] Fatal error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
//# sourceMappingURL=mcp-server.js.map
|