pyre-cli 9.5.1 → 10.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 +84 -14
- package/dist/alertChannels-TPSQIJ7S.js +8 -0
- package/dist/blender-FGH3ASEQ.js +7 -0
- package/dist/brewHealth-P2GW5Y5C.js +176 -0
- package/dist/buildCmd-CDPZ2ZFA.js +38 -0
- package/dist/{checkCmd-EWFNLBWE.js → checkCmd-RKW6QGDR.js} +10 -11
- package/dist/{chunk-RVGKEJXT.js → chunk-53O2RGSL.js} +47 -17
- package/dist/{chunk-MOAEGMFP.js → chunk-6UHZP4QP.js} +204 -46
- package/dist/chunk-GHRDW646.js +2506 -0
- package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
- package/dist/chunk-M73HCQD2.js +95 -0
- package/dist/chunk-MZO5QKUR.js +32 -0
- package/dist/chunk-VIACN2RU.js +133 -0
- package/dist/chunk-Z2YV5HKV.js +87 -0
- package/dist/chunk-ZLVIVFSO.js +110 -0
- package/dist/{collectors-WD6VKH6M.js → collectors-H43PN4PS.js} +8 -2
- package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
- package/dist/{config-4ZLF4HTS.js → config-GYGJXU2Q.js} +9 -1
- package/dist/doctor-GYHFTNZQ.js +387 -0
- package/dist/explainCmd-MF6PIMLW.js +222 -0
- package/dist/extensions-XJGHG7X3.js +134 -0
- package/dist/index.js +841 -206
- package/dist/ioreport.py +72 -0
- package/dist/netUsageCmd-WKBTW2YG.js +90 -0
- package/dist/{pipeCmd-7THXEGDH.js → pipeCmd-7REDBLBU.js} +5 -3
- package/dist/profileProcCmd-N4H6MCBT.js +107 -0
- package/dist/prometheus-5BL5PVLC.js +6 -0
- package/dist/prometheusCmd-PL64BLN5.js +93 -0
- package/dist/replayCmd-Z3PYXY7V.js +164 -0
- package/dist/run-HOGQDDTA.js +8 -0
- package/dist/serveCmd-2H5VLF3P.js +153 -0
- package/dist/service-Q74BBILO.js +181 -0
- package/dist/smartCmd-ODWKSFNG.js +166 -0
- package/dist/syncCmd-A2W3QB3S.js +126 -0
- package/dist/topoCmd-DU2HHZNH.js +148 -0
- package/dist/watchdogCmd-UF53JPZB.js +161 -0
- package/dist/{wizard-GVIAHIVV.js → wizard-TD7BGX6O.js} +1 -1
- package/install.ps1 +29 -0
- package/install.sh +10 -9
- package/package.json +7 -3
- package/src/ai/models.ts +138 -0
- package/src/alertChannels.ts +119 -0
- package/src/anomalies.ts +114 -60
- package/src/brewHealth.ts +157 -40
- package/src/buildCmd.ts +48 -0
- package/src/checkCmd.ts +5 -5
- package/src/completions.ts +35 -0
- package/src/doctor.ts +235 -7
- package/src/easy_features.test.ts +40 -0
- package/src/explainCmd.ts +261 -0
- package/src/extensions.ts +71 -7
- package/src/formatters/index.ts +1 -1
- package/src/formatters/output.ts +100 -13
- package/src/formatters/render.ts +274 -38
- package/src/formatters/themes.ts +8 -0
- package/src/formatters/types.ts +6 -1
- package/src/index.ts +298 -56
- package/src/live/input.ts +122 -4
- package/src/live/render.ts +98 -0
- package/src/live/state.ts +70 -52
- package/src/live/types.ts +2 -2
- package/src/monitors/batteryPredictor.ts +143 -0
- package/src/monitors/blender.ts +144 -0
- package/src/monitors/buildTracker.ts +121 -0
- package/src/monitors/collectors.ts +558 -147
- package/src/monitors/dns_cache.ts +48 -0
- package/src/monitors/index.ts +3 -2
- package/src/monitors/ioreport.py +72 -0
- package/src/monitors/platform/linux.ts +756 -0
- package/src/monitors/platform/windows.ts +519 -0
- package/src/monitors/run.ts +27 -3
- package/src/monitors/smc.ts +21 -1
- package/src/monitors/types.ts +112 -14
- package/src/netUsageCmd.ts +121 -0
- package/src/platform.test.ts +99 -0
- package/src/plugins/index.ts +143 -0
- package/src/profileProcCmd.ts +133 -0
- package/src/prometheusCmd.ts +107 -0
- package/src/replayCmd.ts +186 -0
- package/src/serveCmd.ts +180 -0
- package/src/service.ts +125 -7
- package/src/smartCmd.ts +205 -0
- package/src/state/config.ts +64 -17
- package/src/syncCmd.ts +166 -0
- package/src/topoCmd.ts +176 -0
- package/src/watchdogCmd.ts +198 -0
- package/src/xbar.ts +22 -11
- package/dist/brewHealth-F2BXF5M6.js +0 -74
- package/dist/chunk-FH5WDJBF.js +0 -15
- package/dist/chunk-HGRBBPYX.js +0 -937
- package/dist/doctor-L6FZYA7F.js +0 -184
- package/dist/extensions-DVA5YLGH.js +0 -74
- package/dist/service-WTS3NBQU.js +0 -73
- /package/dist/{chunk-SG26Y3WF.js → chunk-IM3IQMMT.js} +0 -0
package/src/doctor.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import chalk from 'chalk';
|
|
2
4
|
import { run } from './monitors/run.js';
|
|
3
5
|
import { readConfig, CONFIG_FILE } from './state/config.js';
|
|
@@ -10,6 +12,13 @@ export interface DoctorCheck {
|
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export async function runDoctor(): Promise<DoctorCheck[]> {
|
|
15
|
+
const platform = process.platform;
|
|
16
|
+
if (platform === 'linux') return runLinuxDoctor();
|
|
17
|
+
if (platform === 'win32') return runWindowsDoctor();
|
|
18
|
+
return runMacDoctor();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runMacDoctor(): Promise<DoctorCheck[]> {
|
|
13
22
|
const checks: DoctorCheck[] = [];
|
|
14
23
|
|
|
15
24
|
// 1. Sudo / Powermetrics Access
|
|
@@ -163,27 +172,246 @@ export async function runDoctor(): Promise<DoctorCheck[]> {
|
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
// 7. Config file integrity
|
|
175
|
+
checks.push(checkConfigIntegrity());
|
|
176
|
+
|
|
177
|
+
return checks;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function runLinuxDoctor(): Promise<DoctorCheck[]> {
|
|
181
|
+
const checks: DoctorCheck[] = [];
|
|
182
|
+
|
|
183
|
+
// 1. Process Visibility
|
|
184
|
+
try {
|
|
185
|
+
const psCheck = await run('ps -eo pid,user,comm', '');
|
|
186
|
+
if (psCheck.length > 0) {
|
|
187
|
+
checks.push({
|
|
188
|
+
name: 'Process Visibility',
|
|
189
|
+
status: 'ok',
|
|
190
|
+
message: 'Full process table access available.',
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
checks.push({
|
|
194
|
+
name: 'Process Visibility',
|
|
195
|
+
status: 'warn',
|
|
196
|
+
message: 'Restricted process table access.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
checks.push({
|
|
201
|
+
name: 'Process Visibility',
|
|
202
|
+
status: 'error',
|
|
203
|
+
message: 'Unable to query process list.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. /proc and /sys Telemetry Access
|
|
208
|
+
try {
|
|
209
|
+
const statExists = fs.existsSync('/proc/stat');
|
|
210
|
+
const memExists = fs.existsSync('/proc/meminfo');
|
|
211
|
+
if (statExists && memExists) {
|
|
212
|
+
checks.push({
|
|
213
|
+
name: 'Kernel Telemetry (/proc)',
|
|
214
|
+
status: 'ok',
|
|
215
|
+
message: 'Kernel telemetry interfaces readable.',
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
checks.push({
|
|
219
|
+
name: 'Kernel Telemetry (/proc)',
|
|
220
|
+
status: 'error',
|
|
221
|
+
message: 'Core pseudo-filesystems (/proc) unavailable or restricted.',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
checks.push({
|
|
226
|
+
name: 'Kernel Telemetry (/proc)',
|
|
227
|
+
status: 'warn',
|
|
228
|
+
message: 'Could not check /proc readability.',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3. Thermal & Hardware Sensors
|
|
233
|
+
try {
|
|
234
|
+
const thermZones = fs.existsSync('/sys/class/thermal');
|
|
235
|
+
const hwmon = fs.existsSync('/sys/class/hwmon');
|
|
236
|
+
if (thermZones || hwmon) {
|
|
237
|
+
checks.push({
|
|
238
|
+
name: 'Hardware Sensors (/sys)',
|
|
239
|
+
status: 'ok',
|
|
240
|
+
message: 'Thermal and hardware monitoring sensors detected.',
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
checks.push({
|
|
244
|
+
name: 'Hardware Sensors (/sys)',
|
|
245
|
+
status: 'warn',
|
|
246
|
+
message: 'No hardware sensor paths detected in /sys/class.',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
checks.push({
|
|
251
|
+
name: 'Hardware Sensors (/sys)',
|
|
252
|
+
status: 'warn',
|
|
253
|
+
message: 'Unable to query sensor interfaces.',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 4. Network Reachability
|
|
258
|
+
try {
|
|
259
|
+
const netCheck = await run('ip route 2>/dev/null || route -n 2>/dev/null', '');
|
|
260
|
+
if (netCheck.includes('default') || netCheck.includes('0.0.0.0')) {
|
|
261
|
+
checks.push({
|
|
262
|
+
name: 'Network Reachability',
|
|
263
|
+
status: 'ok',
|
|
264
|
+
message: 'Active default route detected.',
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
checks.push({
|
|
268
|
+
name: 'Network Reachability',
|
|
269
|
+
status: 'warn',
|
|
270
|
+
message: 'No default network gateway detected.',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
checks.push({
|
|
275
|
+
name: 'Network Reachability',
|
|
276
|
+
status: 'warn',
|
|
277
|
+
message: 'Could not verify network routes.',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 5. Systemd User Service Capability
|
|
282
|
+
try {
|
|
283
|
+
const sysctlRes = await run('systemctl --user status 2>&1', '');
|
|
284
|
+
if (!sysctlRes.includes('Failed to connect to bus') && !sysctlRes.includes('not found')) {
|
|
285
|
+
checks.push({
|
|
286
|
+
name: 'Systemd User Daemon',
|
|
287
|
+
status: 'ok',
|
|
288
|
+
message: 'Systemd user session active (ready for pyre --install).',
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
checks.push({
|
|
292
|
+
name: 'Systemd User Daemon',
|
|
293
|
+
status: 'warn',
|
|
294
|
+
message: 'Systemd user session not available.',
|
|
295
|
+
details: 'Background services via pyre --install require an active user systemd session.',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
checks.push({
|
|
300
|
+
name: 'Systemd User Daemon',
|
|
301
|
+
status: 'warn',
|
|
302
|
+
message: 'Systemd check skipped.',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 6. Config file integrity
|
|
307
|
+
checks.push(checkConfigIntegrity());
|
|
308
|
+
|
|
309
|
+
return checks;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runWindowsDoctor(): Promise<DoctorCheck[]> {
|
|
313
|
+
const checks: DoctorCheck[] = [];
|
|
314
|
+
|
|
315
|
+
// 1. Process Visibility
|
|
166
316
|
try {
|
|
167
|
-
const
|
|
317
|
+
const psCheck = await run('tasklist /nh 2>nul', '');
|
|
318
|
+
if (psCheck.length > 0) {
|
|
319
|
+
checks.push({
|
|
320
|
+
name: 'Process Visibility',
|
|
321
|
+
status: 'ok',
|
|
322
|
+
message: 'Windows process table access available.',
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
checks.push({
|
|
326
|
+
name: 'Process Visibility',
|
|
327
|
+
status: 'warn',
|
|
328
|
+
message: 'Tasklist query returned no output.',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
168
332
|
checks.push({
|
|
333
|
+
name: 'Process Visibility',
|
|
334
|
+
status: 'error',
|
|
335
|
+
message: 'Unable to query Windows tasklist.',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 2. PowerShell / WMI Interface
|
|
340
|
+
try {
|
|
341
|
+
const wmiCheck = await run('wmic os get Caption 2>nul || powershell -Command "(Get-CimInstance Win32_OperatingSystem).Caption"', '');
|
|
342
|
+
if (wmiCheck.toLowerCase().includes('windows')) {
|
|
343
|
+
checks.push({
|
|
344
|
+
name: 'WMI / CIM Subsystem',
|
|
345
|
+
status: 'ok',
|
|
346
|
+
message: 'WMI/CIM management queries responsive.',
|
|
347
|
+
});
|
|
348
|
+
} else {
|
|
349
|
+
checks.push({
|
|
350
|
+
name: 'WMI / CIM Subsystem',
|
|
351
|
+
status: 'warn',
|
|
352
|
+
message: 'WMI/CIM query did not return expected response.',
|
|
353
|
+
details: 'Some hardware metrics (disks, thermal) rely on WMI or PowerShell CIM.',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
checks.push({
|
|
358
|
+
name: 'WMI / CIM Subsystem',
|
|
359
|
+
status: 'warn',
|
|
360
|
+
message: 'Could not execute WMI query.',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 3. Network Reachability
|
|
365
|
+
try {
|
|
366
|
+
const netCheck = await run('netstat -rn 2>nul', '');
|
|
367
|
+
if (netCheck.includes('0.0.0.0')) {
|
|
368
|
+
checks.push({
|
|
369
|
+
name: 'Network Reachability',
|
|
370
|
+
status: 'ok',
|
|
371
|
+
message: 'Active default IPv4 gateway detected.',
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
checks.push({
|
|
375
|
+
name: 'Network Reachability',
|
|
376
|
+
status: 'warn',
|
|
377
|
+
message: 'No default route detected in routing table.',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
checks.push({
|
|
382
|
+
name: 'Network Reachability',
|
|
383
|
+
status: 'warn',
|
|
384
|
+
message: 'Could not verify Windows network routes.',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 4. Config file integrity
|
|
389
|
+
checks.push(checkConfigIntegrity());
|
|
390
|
+
|
|
391
|
+
return checks;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function checkConfigIntegrity(): DoctorCheck {
|
|
395
|
+
try {
|
|
396
|
+
readConfig();
|
|
397
|
+
return {
|
|
169
398
|
name: 'Configuration File',
|
|
170
399
|
status: 'ok',
|
|
171
400
|
message: `Config file valid (${CONFIG_FILE}).`,
|
|
172
|
-
}
|
|
401
|
+
};
|
|
173
402
|
} catch (err: any) {
|
|
174
|
-
|
|
403
|
+
return {
|
|
175
404
|
name: 'Configuration File',
|
|
176
405
|
status: 'error',
|
|
177
406
|
message: 'Failed to parse config file.',
|
|
178
407
|
details: err.message,
|
|
179
|
-
}
|
|
408
|
+
};
|
|
180
409
|
}
|
|
181
|
-
|
|
182
|
-
return checks;
|
|
183
410
|
}
|
|
184
411
|
|
|
185
412
|
export function printDoctorReport(checks: DoctorCheck[]): void {
|
|
186
|
-
|
|
413
|
+
const osLabel = process.platform === 'darwin' ? 'macOS' : (process.platform === 'win32' ? 'Windows' : 'Linux');
|
|
414
|
+
console.log(chalk.bold(`\n pyre doctor — System Diagnostics (${osLabel})\n`));
|
|
187
415
|
|
|
188
416
|
for (const check of checks) {
|
|
189
417
|
let icon = chalk.green('✔');
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
process.env.PYRE_CONFIG_DIR = path.join(process.cwd(), 'node_modules', '.test-pyre-config');
|
|
7
|
+
|
|
3
8
|
import { parseSystemExtensionsOutput } from './extensions.js';
|
|
4
9
|
import { saveProfile, loadProfile, listProfiles } from './state/config.js';
|
|
5
10
|
import { sparkline } from './sparkline.js';
|
|
@@ -41,3 +46,38 @@ describe('Plain text sparkline (Feature F3)', () => {
|
|
|
41
46
|
assert.strictEqual(res, '[10.0 -> 30.0 (Δ:+20.0)]');
|
|
42
47
|
});
|
|
43
48
|
});
|
|
49
|
+
|
|
50
|
+
describe('Cross-Platform Support', () => {
|
|
51
|
+
it('should generate valid PowerShell completions script', async () => {
|
|
52
|
+
const { generatePowerShellCompletions } = await import('./completions.js');
|
|
53
|
+
const ps = generatePowerShellCompletions();
|
|
54
|
+
assert.ok(ps.includes('Register-ArgumentCompleter'));
|
|
55
|
+
assert.ok(ps.includes('CommandName pyre'));
|
|
56
|
+
assert.ok(ps.includes('live'));
|
|
57
|
+
assert.ok(ps.includes('--json'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should run doctor diagnostics without crashing on any platform', async () => {
|
|
61
|
+
const { runDoctor } = await import('./doctor.js');
|
|
62
|
+
const checks = await runDoctor();
|
|
63
|
+
assert.ok(Array.isArray(checks));
|
|
64
|
+
assert.ok(checks.length > 0);
|
|
65
|
+
for (const c of checks) {
|
|
66
|
+
assert.ok(['ok', 'warn', 'error'].includes(c.status));
|
|
67
|
+
assert.ok(typeof c.name === 'string');
|
|
68
|
+
assert.ok(typeof c.message === 'string');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should collect telemetry across subsystems without crashing', async () => {
|
|
73
|
+
const { collectAll } = await import('./monitors/index.js');
|
|
74
|
+
const data = await collectAll();
|
|
75
|
+
assert.ok(data.header.hostname.length > 0);
|
|
76
|
+
assert.ok(data.header.os.length > 0);
|
|
77
|
+
assert.ok(data.cpu.cores >= 1);
|
|
78
|
+
assert.ok(data.memory.total > 0);
|
|
79
|
+
assert.ok(Array.isArray(data.disk));
|
|
80
|
+
assert.ok(typeof data.network.interface === 'string');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyre explain — AI-powered anomaly explanation.
|
|
3
|
+
*
|
|
4
|
+
* Reads anomaly spikes from pyre CSV logs and sends them to a
|
|
5
|
+
* local Ollama instance (default) or an OpenAI-compatible API
|
|
6
|
+
* to generate plain-English diagnosis and actionable advice.
|
|
7
|
+
*
|
|
8
|
+
* Backends:
|
|
9
|
+
* - ollama (default): POST http://localhost:11434/api/generate
|
|
10
|
+
* - openai: POST https://api.openai.com/v1/chat/completions
|
|
11
|
+
*
|
|
12
|
+
* No new dependencies — uses Node.js 18+ built-in fetch.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { explainAnomaliesLocally } from './ai/models.js';
|
|
19
|
+
|
|
20
|
+
export interface ExplainOptions {
|
|
21
|
+
since?: string;
|
|
22
|
+
backend?: 'builtin' | 'ollama' | 'openai';
|
|
23
|
+
model?: string;
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
dir?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AnomalyEvent {
|
|
29
|
+
timestamp: string;
|
|
30
|
+
metric: string;
|
|
31
|
+
value: number;
|
|
32
|
+
baseline: number;
|
|
33
|
+
zScore: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseAnomalies(dir: string, sinceStr: string): AnomalyEvent[] {
|
|
37
|
+
if (!fs.existsSync(dir)) return [];
|
|
38
|
+
const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-') && f.endsWith('.csv'));
|
|
39
|
+
if (files.length === 0) return [];
|
|
40
|
+
|
|
41
|
+
let cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
42
|
+
if (sinceStr === 'yesterday' || sinceStr === '1d') cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
43
|
+
else if (sinceStr.endsWith('d')) {
|
|
44
|
+
const d = parseInt(sinceStr.slice(0, -1), 10);
|
|
45
|
+
if (!isNaN(d)) cutoff = Date.now() - d * 24 * 60 * 60 * 1000;
|
|
46
|
+
} else if (!isNaN(Date.parse(sinceStr))) {
|
|
47
|
+
cutoff = Date.parse(sinceStr);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface LogRow { ts: Date; cpu: number; mem: number; temp?: number; }
|
|
51
|
+
const rows: LogRow[] = [];
|
|
52
|
+
|
|
53
|
+
for (const file of files.sort()) {
|
|
54
|
+
try {
|
|
55
|
+
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
|
|
56
|
+
for (const line of content.split('\n').slice(1)) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
const parts = line.split(',');
|
|
59
|
+
if (parts.length < 3) continue;
|
|
60
|
+
const ts = new Date(parts[0]);
|
|
61
|
+
if (!isNaN(ts.getTime()) && ts.getTime() >= cutoff) {
|
|
62
|
+
const cpu = parseFloat(parts[1]);
|
|
63
|
+
const mem = parseFloat(parts[2]);
|
|
64
|
+
const temp = parts[3] ? parseFloat(parts[3]) : NaN;
|
|
65
|
+
if (!isNaN(cpu) && !isNaN(mem)) {
|
|
66
|
+
rows.push({ ts, cpu, mem, temp: isNaN(temp) ? undefined : temp });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch { /* ignore */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (rows.length < 5) return [];
|
|
74
|
+
|
|
75
|
+
const mean = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
76
|
+
const std = (arr: number[], m: number) => Math.sqrt(arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length);
|
|
77
|
+
|
|
78
|
+
const cpuVals = rows.map(r => r.cpu);
|
|
79
|
+
const memVals = rows.map(r => r.mem);
|
|
80
|
+
const tempVals = rows.filter(r => r.temp !== undefined).map(r => r.temp!);
|
|
81
|
+
|
|
82
|
+
const cpuMean = mean(cpuVals); const cpuStd = std(cpuVals, cpuMean);
|
|
83
|
+
const memMean = mean(memVals); const memStd = std(memVals, memMean);
|
|
84
|
+
const tempMean = tempVals.length ? mean(tempVals) : 0;
|
|
85
|
+
const tempStd = tempVals.length ? std(tempVals, tempMean) : 0;
|
|
86
|
+
|
|
87
|
+
const anomalies: AnomalyEvent[] = [];
|
|
88
|
+
for (const r of rows) {
|
|
89
|
+
if (cpuStd > 0) {
|
|
90
|
+
const z = (r.cpu - cpuMean) / cpuStd;
|
|
91
|
+
if (z >= 2.5) anomalies.push({ timestamp: r.ts.toISOString(), metric: 'CPU', value: r.cpu, baseline: cpuMean, zScore: z });
|
|
92
|
+
}
|
|
93
|
+
if (memStd > 0) {
|
|
94
|
+
const z = (r.mem - memMean) / memStd;
|
|
95
|
+
if (z >= 2.5) anomalies.push({ timestamp: r.ts.toISOString(), metric: 'Memory', value: r.mem, baseline: memMean, zScore: z });
|
|
96
|
+
}
|
|
97
|
+
if (r.temp !== undefined && tempStd > 0) {
|
|
98
|
+
const z = (r.temp - tempMean) / tempStd;
|
|
99
|
+
if (z >= 2.5) anomalies.push({ timestamp: r.ts.toISOString(), metric: 'Temperature', value: r.temp, baseline: tempMean, zScore: z });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return anomalies;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildPrompt(anomalies: AnomalyEvent[], hostname: string, sinceStr: string): string {
|
|
106
|
+
const osLabel = process.platform === 'darwin' ? 'macOS' : (process.platform === 'win32' ? 'Windows' : 'Linux');
|
|
107
|
+
const lines = anomalies.slice(0, 10).map(a => {
|
|
108
|
+
const unit = a.metric === 'Temperature' ? '°C' : '%';
|
|
109
|
+
return `- ${a.metric} spike at ${a.timestamp}: ${a.value.toFixed(1)}${unit} (baseline ${a.baseline.toFixed(1)}${unit}, +${a.zScore.toFixed(1)}σ)`;
|
|
110
|
+
});
|
|
111
|
+
return [
|
|
112
|
+
`You are a ${osLabel} system performance expert analyzing a monitoring log from "${hostname}".`,
|
|
113
|
+
`The following statistical anomalies (z-score ≥ 2.5σ) were detected in the past ${sinceStr}:`,
|
|
114
|
+
'',
|
|
115
|
+
...lines,
|
|
116
|
+
'',
|
|
117
|
+
'For each anomaly, provide:',
|
|
118
|
+
`1. A plain-English explanation of what likely caused it (be specific to ${osLabel})`,
|
|
119
|
+
'2. Whether it is likely harmless or worth investigating',
|
|
120
|
+
'3. One actionable recommendation the user can take right now',
|
|
121
|
+
'',
|
|
122
|
+
'Be concise (2-3 sentences per anomaly). Use bullet points. Do not use technical jargon.',
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function queryOllama(prompt: string, model: string): Promise<string> {
|
|
127
|
+
const res = await fetch('http://localhost:11434/api/generate', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ model, prompt, stream: false }),
|
|
131
|
+
signal: AbortSignal.timeout(60000),
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok) throw new Error(`Ollama error: ${res.status} ${res.statusText}`);
|
|
134
|
+
const data = await res.json() as { response?: string; error?: string };
|
|
135
|
+
if (data.error) throw new Error(data.error);
|
|
136
|
+
return data.response ?? '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function queryOpenAI(prompt: string, model: string, apiKey: string): Promise<string> {
|
|
140
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
143
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], max_tokens: 800 }),
|
|
144
|
+
signal: AbortSignal.timeout(60000),
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) throw new Error(`OpenAI error: ${res.status} ${await res.text()}`);
|
|
147
|
+
const data = await res.json() as { choices?: Array<{ message: { content: string } }> };
|
|
148
|
+
return data.choices?.[0]?.message?.content ?? '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runExplainCommand(opts: ExplainOptions = {}): Promise<void> {
|
|
152
|
+
const {
|
|
153
|
+
since = '7d',
|
|
154
|
+
backend = 'builtin',
|
|
155
|
+
model = backend === 'openai' ? 'gpt-4o-mini' : backend === 'ollama' ? 'llama3.2' : 'expert-rules-v1',
|
|
156
|
+
apiKey = process.env['OPENAI_API_KEY'] ?? '',
|
|
157
|
+
dir = './pyre-exports',
|
|
158
|
+
} = opts;
|
|
159
|
+
|
|
160
|
+
console.log(chalk.bold('\n 🔥 pyre explain — AI Anomaly Analysis\n'));
|
|
161
|
+
|
|
162
|
+
const anomalies = parseAnomalies(dir, since);
|
|
163
|
+
|
|
164
|
+
if (anomalies.length === 0) {
|
|
165
|
+
console.log(chalk.green(' ✔ No anomalies detected in the past ' + since + '.'));
|
|
166
|
+
console.log(chalk.dim(' Run pyre live --log to build a history first.\n'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (backend === 'builtin') {
|
|
171
|
+
console.log(chalk.dim(` Analyzing ${anomalies.length} anomaly event(s) using local built-in model (${model})...\n`));
|
|
172
|
+
const alerts = anomalies.map(a => ({
|
|
173
|
+
metric: a.metric,
|
|
174
|
+
value: a.value,
|
|
175
|
+
zScore: a.zScore,
|
|
176
|
+
severity: (a.zScore >= 3.5 ? 'critical' : 'warning') as 'critical' | 'warning',
|
|
177
|
+
timestamp: new Date(a.timestamp),
|
|
178
|
+
}));
|
|
179
|
+
const analysis = explainAnomaliesLocally(alerts, model);
|
|
180
|
+
console.log(chalk.bold(' Analysis:\n'));
|
|
181
|
+
for (const line of analysis.split('\n')) {
|
|
182
|
+
if (line.startsWith('Diagnosis Engine:')) {
|
|
183
|
+
console.log(chalk.bold.magenta(' ' + line));
|
|
184
|
+
} else if (line.startsWith('Tip:')) {
|
|
185
|
+
console.log(chalk.cyan.bold(' ' + line));
|
|
186
|
+
} else if (line.startsWith(' • Recommendation:')) {
|
|
187
|
+
console.log(' ' + chalk.green(line));
|
|
188
|
+
} else if (line.startsWith(' • Cause:')) {
|
|
189
|
+
console.log(' ' + chalk.yellow(line));
|
|
190
|
+
} else if (line.startsWith(' • Verdict:')) {
|
|
191
|
+
console.log(' ' + chalk.dim(line));
|
|
192
|
+
} else {
|
|
193
|
+
console.log(' ' + chalk.dim(line));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
console.log();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(chalk.dim(` Found ${anomalies.length} anomaly event(s). Sending to ${backend} (${model})...\n`));
|
|
201
|
+
|
|
202
|
+
const hostname = os.hostname();
|
|
203
|
+
const prompt = buildPrompt(anomalies, hostname, since);
|
|
204
|
+
let explanation = '';
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
if (backend === 'openai') {
|
|
208
|
+
if (!apiKey) {
|
|
209
|
+
console.log(chalk.yellow(' ⚠ No API key found.'));
|
|
210
|
+
console.log(chalk.dim(' Set OPENAI_API_KEY environment variable or use --api-key <key>\n'));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
explanation = await queryOpenAI(prompt, model, apiKey);
|
|
214
|
+
} else {
|
|
215
|
+
explanation = await queryOllama(prompt, model);
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
219
|
+
if (backend === 'ollama' && (msg.includes('ECONNREFUSED') || msg.includes('fetch failed'))) {
|
|
220
|
+
console.log(chalk.yellow(' ⚠ Ollama server is not running at localhost:11434.\n'));
|
|
221
|
+
console.log(chalk.dim(' Switching to local built-in AI model (offline, zero-setup)...\n'));
|
|
222
|
+
const alerts = anomalies.map(a => ({
|
|
223
|
+
metric: a.metric,
|
|
224
|
+
value: a.value,
|
|
225
|
+
zScore: a.zScore,
|
|
226
|
+
severity: (a.zScore >= 3.5 ? 'critical' : 'warning') as 'critical' | 'warning',
|
|
227
|
+
timestamp: new Date(a.timestamp),
|
|
228
|
+
}));
|
|
229
|
+
const fallbackAnalysis = explainAnomaliesLocally(alerts, 'expert-rules-v1');
|
|
230
|
+
console.log(chalk.bold(' Analysis (Built-in Heuristic Model):\n'));
|
|
231
|
+
for (const line of fallbackAnalysis.split('\n')) {
|
|
232
|
+
console.log(' ' + line);
|
|
233
|
+
}
|
|
234
|
+
console.log();
|
|
235
|
+
return;
|
|
236
|
+
} else {
|
|
237
|
+
console.log(chalk.red(` ✖ LLM error: ${msg}\n`));
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!explanation.trim()) {
|
|
243
|
+
console.log(chalk.yellow(' ⚠ LLM returned an empty response. Try a different model.\n'));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
console.log(chalk.bold(' Analysis:\n'));
|
|
248
|
+
for (const line of explanation.split('\n')) {
|
|
249
|
+
const t = line.trim();
|
|
250
|
+
if (t.startsWith('-') || t.startsWith('•')) {
|
|
251
|
+
console.log(chalk.dim(' ') + chalk.white(t));
|
|
252
|
+
} else if (t.match(/^\d+\./)) {
|
|
253
|
+
console.log(chalk.dim(' ') + chalk.cyan(t));
|
|
254
|
+
} else if (t) {
|
|
255
|
+
console.log(' ' + t);
|
|
256
|
+
} else {
|
|
257
|
+
console.log('');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
console.log();
|
|
261
|
+
}
|
package/src/extensions.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface SystemExtensionInfo {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export async function getSystemExtensions(): Promise<{ category: string; extensions: SystemExtensionInfo[] }[]> {
|
|
13
|
+
const platform = process.platform;
|
|
14
|
+
if (platform === 'linux') return getLinuxExtensions();
|
|
15
|
+
if (platform === 'win32') return getWindowsExtensions();
|
|
16
|
+
|
|
13
17
|
try {
|
|
14
18
|
const raw = await run('systemextensionsctl list 2>&1', '');
|
|
15
19
|
return parseSystemExtensionsOutput(raw);
|
|
@@ -33,12 +37,12 @@ export function parseSystemExtensionsOutput(output: string): { category: string;
|
|
|
33
37
|
result.push({ category: currentCategory, extensions: currentList });
|
|
34
38
|
currentList = [];
|
|
35
39
|
}
|
|
36
|
-
const catMatch = trimmed.match(/category\s+([\w
|
|
40
|
+
const catMatch = trimmed.match(/category\s+([\w.-]+)/i);
|
|
37
41
|
currentCategory = catMatch ? catMatch[1] : trimmed.replace(/^-+\s*/, '').replace(/\s*-+$/, '');
|
|
38
42
|
continue;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
const extMatch = trimmed.match(/^(?:\*\s*)*([A-Z0-9]{10}|\?\?\?\?\?\?\?\?\?\?)\s+([\w
|
|
45
|
+
const extMatch = trimmed.match(/^(?:\*\s*)*([A-Z0-9]{10}|\?\?\?\?\?\?\?\?\?\?)\s+([\w.-]+)\s+\(([^)]+)\)\s+\[([^\]]+)\]/i);
|
|
42
46
|
if (extMatch) {
|
|
43
47
|
currentList.push({
|
|
44
48
|
teamId: extMatch[1],
|
|
@@ -57,8 +61,63 @@ export function parseSystemExtensionsOutput(output: string): { category: string;
|
|
|
57
61
|
return result.filter(group => group.extensions.length > 0 || result.length === 1);
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
async function getLinuxExtensions(): Promise<{ category: string; extensions: SystemExtensionInfo[] }[]> {
|
|
65
|
+
try {
|
|
66
|
+
const raw = await run('lsmod 2>/dev/null', '');
|
|
67
|
+
const lines = raw.trim().split('\n').slice(1);
|
|
68
|
+
const extensions: SystemExtensionInfo[] = [];
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const parts = line.trim().split(/\s+/);
|
|
72
|
+
if (parts.length >= 3) {
|
|
73
|
+
const modName = parts[0];
|
|
74
|
+
const sizeBytes = parts[1];
|
|
75
|
+
const usedBy = parts.slice(3).join(' ') || 'none';
|
|
76
|
+
extensions.push({
|
|
77
|
+
teamId: 'KERNEL',
|
|
78
|
+
bundleId: modName,
|
|
79
|
+
version: `${sizeBytes} bytes`,
|
|
80
|
+
state: `used by: ${usedBy}`,
|
|
81
|
+
category: 'Kernel Modules',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return extensions.length > 0 ? [{ category: 'Linux Kernel Modules', extensions }] : [];
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getWindowsExtensions(): Promise<{ category: string; extensions: SystemExtensionInfo[] }[]> {
|
|
92
|
+
try {
|
|
93
|
+
const raw = await run('driverquery /fo csv /nh 2>nul', '', 3000);
|
|
94
|
+
const lines = raw.trim().split('\n');
|
|
95
|
+
const extensions: SystemExtensionInfo[] = [];
|
|
96
|
+
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const parts = line.split('","').map(s => s.replace(/(^"|"$)/g, '').trim());
|
|
99
|
+
if (parts.length >= 3) {
|
|
100
|
+
const modName = parts[0];
|
|
101
|
+
const dispName = parts[1];
|
|
102
|
+
const driverType = parts[2];
|
|
103
|
+
extensions.push({
|
|
104
|
+
teamId: driverType,
|
|
105
|
+
bundleId: modName,
|
|
106
|
+
version: dispName,
|
|
107
|
+
state: 'active',
|
|
108
|
+
category: 'Device Drivers',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return extensions.length > 0 ? [{ category: 'Windows Device Drivers', extensions }] : [];
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
60
118
|
export async function printExtensionsReport(): Promise<void> {
|
|
61
|
-
|
|
119
|
+
const osLabel = process.platform === 'darwin' ? 'macOS System Extensions' : (process.platform === 'win32' ? 'Windows Drivers' : 'Linux Kernel Modules');
|
|
120
|
+
console.log(chalk.bold(`\n pyre extensions — ${osLabel} Inspector\n`));
|
|
62
121
|
const categories = await getSystemExtensions();
|
|
63
122
|
|
|
64
123
|
let totalCount = 0;
|
|
@@ -69,15 +128,20 @@ export async function printExtensionsReport(): Promise<void> {
|
|
|
69
128
|
console.log(chalk.dim(' (No extensions registered in this category)'));
|
|
70
129
|
continue;
|
|
71
130
|
}
|
|
72
|
-
|
|
73
|
-
|
|
131
|
+
// Limit display so large module/driver tables do not overwhelm the terminal
|
|
132
|
+
const maxShow = 25;
|
|
133
|
+
for (const ext of cat.extensions.slice(0, maxShow)) {
|
|
134
|
+
const stateColor = ext.state.includes('activated') || ext.state.includes('enabled') || ext.state.includes('active') ? chalk.green : chalk.yellow;
|
|
74
135
|
console.log(` • ${chalk.bold(ext.bundleId)} (${ext.version})`);
|
|
75
|
-
console.log(chalk.dim(` Team: ${ext.teamId} | State: `) + stateColor(ext.state));
|
|
136
|
+
console.log(chalk.dim(` Type/Team: ${ext.teamId} | State: `) + stateColor(ext.state));
|
|
137
|
+
}
|
|
138
|
+
if (cat.extensions.length > maxShow) {
|
|
139
|
+
console.log(chalk.dim(` ... and ${cat.extensions.length - maxShow} more.`));
|
|
76
140
|
}
|
|
77
141
|
console.log();
|
|
78
142
|
}
|
|
79
143
|
|
|
80
144
|
if (totalCount === 0) {
|
|
81
|
-
console.log(chalk.dim(' No active
|
|
145
|
+
console.log(chalk.dim(' No active extensions/modules detected on this system.\n'));
|
|
82
146
|
}
|
|
83
147
|
}
|