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.
Files changed (94) hide show
  1. package/README.md +84 -14
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/blender-FGH3ASEQ.js +7 -0
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-EWFNLBWE.js → checkCmd-RKW6QGDR.js} +10 -11
  7. package/dist/{chunk-RVGKEJXT.js → chunk-53O2RGSL.js} +47 -17
  8. package/dist/{chunk-MOAEGMFP.js → chunk-6UHZP4QP.js} +204 -46
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/chunk-VIACN2RU.js +133 -0
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-WD6VKH6M.js → collectors-H43PN4PS.js} +8 -2
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-4ZLF4HTS.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +841 -206
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-7THXEGDH.js → pipeCmd-7REDBLBU.js} +5 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-GVIAHIVV.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/anomalies.ts +114 -60
  44. package/src/brewHealth.ts +157 -40
  45. package/src/buildCmd.ts +48 -0
  46. package/src/checkCmd.ts +5 -5
  47. package/src/completions.ts +35 -0
  48. package/src/doctor.ts +235 -7
  49. package/src/easy_features.test.ts +40 -0
  50. package/src/explainCmd.ts +261 -0
  51. package/src/extensions.ts +71 -7
  52. package/src/formatters/index.ts +1 -1
  53. package/src/formatters/output.ts +100 -13
  54. package/src/formatters/render.ts +274 -38
  55. package/src/formatters/themes.ts +8 -0
  56. package/src/formatters/types.ts +6 -1
  57. package/src/index.ts +298 -56
  58. package/src/live/input.ts +122 -4
  59. package/src/live/render.ts +98 -0
  60. package/src/live/state.ts +70 -52
  61. package/src/live/types.ts +2 -2
  62. package/src/monitors/batteryPredictor.ts +143 -0
  63. package/src/monitors/blender.ts +144 -0
  64. package/src/monitors/buildTracker.ts +121 -0
  65. package/src/monitors/collectors.ts +558 -147
  66. package/src/monitors/dns_cache.ts +48 -0
  67. package/src/monitors/index.ts +3 -2
  68. package/src/monitors/ioreport.py +72 -0
  69. package/src/monitors/platform/linux.ts +756 -0
  70. package/src/monitors/platform/windows.ts +519 -0
  71. package/src/monitors/run.ts +27 -3
  72. package/src/monitors/smc.ts +21 -1
  73. package/src/monitors/types.ts +112 -14
  74. package/src/netUsageCmd.ts +121 -0
  75. package/src/platform.test.ts +99 -0
  76. package/src/plugins/index.ts +143 -0
  77. package/src/profileProcCmd.ts +133 -0
  78. package/src/prometheusCmd.ts +107 -0
  79. package/src/replayCmd.ts +186 -0
  80. package/src/serveCmd.ts +180 -0
  81. package/src/service.ts +125 -7
  82. package/src/smartCmd.ts +205 -0
  83. package/src/state/config.ts +64 -17
  84. package/src/syncCmd.ts +166 -0
  85. package/src/topoCmd.ts +176 -0
  86. package/src/watchdogCmd.ts +198 -0
  87. package/src/xbar.ts +22 -11
  88. package/dist/brewHealth-F2BXF5M6.js +0 -74
  89. package/dist/chunk-FH5WDJBF.js +0 -15
  90. package/dist/chunk-HGRBBPYX.js +0 -937
  91. package/dist/doctor-L6FZYA7F.js +0 -184
  92. package/dist/extensions-DVA5YLGH.js +0 -74
  93. package/dist/service-WTS3NBQU.js +0 -73
  94. /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 cfg = readConfig();
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
- checks.push({
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
- console.log(chalk.bold('\n pyre doctor — System Diagnostics\n'));
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\.-]+)/i);
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\.-]+)\s+\(([^)]+)\)\s+\[([^\]]+)\]/i);
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
- console.log(chalk.bold('\n pyre extensions — System Extensions Inspector\n'));
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
- for (const ext of cat.extensions) {
73
- const stateColor = ext.state.includes('activated') || ext.state.includes('enabled') ? chalk.green : chalk.yellow;
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 system extensions detected on this system.\n'));
145
+ console.log(chalk.dim(' No active extensions/modules detected on this system.\n'));
82
146
  }
83
147
  }