@nocville/npc 0.2.2 → 0.3.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 (105) hide show
  1. package/README.md +55 -17
  2. package/dist/cjs/cli.js +94 -39
  3. package/dist/cjs/core/base-npc.js +15 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +21 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/agent-common/shim.js +8 -2
  14. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  15. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  16. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  17. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  18. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  19. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  20. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  21. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  22. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  23. package/dist/cjs/npcs/office-characters/index.js +92 -0
  24. package/dist/cjs/npcs/process-watcher/index.js +60 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +6 -93
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/security/index.js +26 -0
  30. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  31. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  32. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  33. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  34. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  35. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  36. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  37. package/dist/cli.js +95 -40
  38. package/dist/core/base-npc.d.ts +5 -0
  39. package/dist/core/base-npc.js +15 -0
  40. package/dist/core/config.d.ts +5 -0
  41. package/dist/core/config.js +18 -2
  42. package/dist/core/errors.d.ts +11 -0
  43. package/dist/core/errors.js +18 -0
  44. package/dist/core/http.d.ts +52 -0
  45. package/dist/core/http.js +111 -0
  46. package/dist/core/pixel-art.d.ts +38 -0
  47. package/dist/core/pixel-art.js +105 -0
  48. package/dist/core/polling-npc.d.ts +22 -1
  49. package/dist/core/polling-npc.js +98 -13
  50. package/dist/core/text.d.ts +6 -0
  51. package/dist/core/text.js +12 -0
  52. package/dist/core/thresholds.d.ts +9 -1
  53. package/dist/core/thresholds.js +27 -0
  54. package/dist/core/types.d.ts +5 -0
  55. package/dist/index.d.ts +7 -2
  56. package/dist/index.js +6 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/agent-common/shim.js +8 -2
  59. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  60. package/dist/npcs/github-watcher/defaults.js +7 -1
  61. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  62. package/dist/npcs/github-watcher/github-api.js +80 -45
  63. package/dist/npcs/github-watcher/index.d.ts +6 -0
  64. package/dist/npcs/github-watcher/index.js +83 -37
  65. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  66. package/dist/npcs/internet-connectivity/checks.js +70 -67
  67. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  68. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  69. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  70. package/dist/npcs/internet-connectivity/index.js +135 -133
  71. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  72. package/dist/npcs/log-watcher/buffer.js +58 -0
  73. package/dist/npcs/log-watcher/index.d.ts +22 -21
  74. package/dist/npcs/log-watcher/index.js +112 -106
  75. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  76. package/dist/npcs/log-watcher/tail.js +101 -54
  77. package/dist/npcs/office-characters/index.d.ts +50 -0
  78. package/dist/npcs/office-characters/index.js +82 -0
  79. package/dist/npcs/process-watcher/index.d.ts +1 -1
  80. package/dist/npcs/process-watcher/index.js +61 -52
  81. package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
  82. package/dist/npcs/process-watcher/process-scanner.js +97 -70
  83. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  84. package/dist/npcs/receptionist/defaults.js +4 -0
  85. package/dist/npcs/receptionist/index.d.ts +3 -12
  86. package/dist/npcs/receptionist/index.js +6 -93
  87. package/dist/npcs/receptionist/weather.d.ts +8 -6
  88. package/dist/npcs/receptionist/weather.js +39 -45
  89. package/dist/npcs/security/index.d.ts +12 -0
  90. package/dist/npcs/security/index.js +22 -0
  91. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  92. package/dist/npcs/system-monitor/collectors.js +64 -17
  93. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  94. package/dist/npcs/system-monitor/index.d.ts +2 -1
  95. package/dist/npcs/system-monitor/index.js +54 -30
  96. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  97. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  98. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  99. package/dist/npcs/web-page-watcher/capture.js +21 -1
  100. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  101. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  102. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  103. package/dist/npcs/web-page-watcher/index.js +121 -70
  104. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  105. package/package.json +2 -2
@@ -1,22 +1,7 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { DEFAULTS } from './defaults.js';
3
- import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit } from './weather.js';
4
2
  export class ReceptionistNPC extends PollingNPC {
5
- explicitCoords;
6
- location;
7
- fahrenheit;
8
- coords = null;
9
- geoAttempted = false;
10
- weather = null;
11
- lastWeatherFetch = 0;
12
3
  constructor(config) {
13
- super({ ...config, pollInterval: config.pollInterval ?? DEFAULTS.pollInterval });
14
- this.explicitCoords =
15
- config.lat !== undefined && config.lon !== undefined
16
- ? { lat: config.lat, lon: config.lon }
17
- : null;
18
- this.location = config.location;
19
- this.fahrenheit = config.fahrenheit ?? false;
4
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
20
5
  }
21
6
  get name() {
22
7
  return 'receptionist';
@@ -29,88 +14,16 @@ export class ReceptionistNPC extends PollingNPC {
29
14
  }
30
15
  get behavior() {
31
16
  return {
17
+ role: 'receptionist',
18
+ activity: 'idle',
32
19
  greetPlayers: true,
33
- patrol: { everyMinutes: [15, 30], stops: 5 },
34
20
  idle: 'stay',
35
- home: { near: 'entrance' },
36
- };
37
- }
38
- get companion() {
39
- return {
40
- screenType: 'welcome-sign',
41
- size: { w: 2, h: 1 },
42
- placement: 'adjacent-wall',
43
- label: 'Welcome',
21
+ idlePose: 'sit',
44
22
  };
45
23
  }
46
24
  async collect() {
47
- await this.refreshWeather();
48
- const now = new Date();
49
- const { day, date, time } = formatClock(now);
50
- const values = [
51
- { label: 'time', value: time },
52
- { label: 'day', value: day },
53
- { label: 'date', value: date },
54
- ];
55
- let message = `${day} ${time}`;
56
- const metrics = { weather_ok: this.weather ? 1 : 0 };
57
- if (this.weather) {
58
- const temp = this.fahrenheit
59
- ? `${toFahrenheit(this.weather.tempC)}°F`
60
- : `${this.weather.tempC}°C`;
61
- message += ` • ${temp} ${this.weather.text}`;
62
- values.push({ label: 'temp', value: temp }, { label: 'condition', value: this.weather.text }, { label: 'icon', value: this.weather.icon });
63
- metrics['temp_c'] = this.weather.tempC;
64
- metrics['weather_code'] = this.weather.code;
65
- metrics['wind_kph'] = this.weather.windKph;
66
- }
67
- // The receptionist is a greeter/clock — it stays healthy even when weather
68
- // is unavailable (degraded time-only mode).
69
- return {
70
- status: 'healthy',
71
- mood: 'happy',
72
- message,
73
- screenData: { layout: 'welcome-sign', values },
74
- metrics,
75
- };
76
- }
77
- async refreshWeather() {
78
- if (!this.coords) {
79
- await this.resolveCoords();
80
- }
81
- if (!this.coords)
82
- return;
83
- const now = Date.now();
84
- if (this.weather && now - this.lastWeatherFetch < DEFAULTS.weatherRefreshMs)
85
- return;
86
- const w = await fetchWeather(this.coords, DEFAULTS.fetchTimeoutMs);
87
- if (w) {
88
- this.weather = w;
89
- this.lastWeatherFetch = now;
90
- }
91
- }
92
- async resolveCoords() {
93
- if (this.explicitCoords) {
94
- this.coords = this.explicitCoords;
95
- return;
96
- }
97
- // Only attempt geocoding/geo-IP once per (failed) resolution to avoid
98
- // hammering the keyless endpoints every poll.
99
- if (this.geoAttempted)
100
- return;
101
- this.geoAttempted = true;
102
- if (this.location) {
103
- this.coords = await geocodeLocation(this.location, DEFAULTS.fetchTimeoutMs);
104
- if (this.coords)
105
- return;
106
- this.logger.warn(`Could not geocode "${this.location}", falling back to IP geo`);
107
- }
108
- this.coords = await autoGeo(DEFAULTS.fetchTimeoutMs);
109
- if (!this.coords) {
110
- this.logger.warn('Weather geo unavailable — running in time-only mode');
111
- // Allow a later retry.
112
- this.geoAttempted = false;
113
- }
25
+ // The server handles conversation and movement. Heartbeats stay quiet.
26
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
114
27
  }
115
28
  }
116
29
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,3 @@
1
- /**
2
- * Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
3
- * All network helpers return null on failure so the receptionist can degrade
4
- * to a time-only mode without erroring.
5
- */
6
1
  export interface Coords {
7
2
  lat: number;
8
3
  lon: number;
@@ -20,9 +15,16 @@ export declare function describeWmo(code: number): {
20
15
  text: string;
21
16
  icon: string;
22
17
  };
18
+ /** A finite, in-range latitude/longitude pair. */
19
+ export declare function validCoords(lat: unknown, lon: unknown): boolean;
23
20
  /** Resolve a place name to coordinates via the open-meteo geocoding API. */
24
21
  export declare function geocodeLocation(name: string, timeoutMs?: number): Promise<Coords | null>;
25
- /** Best-effort IP geolocation via ipapi.co (keyless). */
22
+ /**
23
+ * Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
24
+ * public IP to a third party, so the receptionist only calls it when no place
25
+ * was configured and the user hasn't opted out. Only the coordinates are used;
26
+ * nothing from the response is pushed to Nocville.
27
+ */
26
28
  export declare function autoGeo(timeoutMs?: number): Promise<Coords | null>;
27
29
  /** Fetch current temperature, weather code, and wind for coordinates. */
28
30
  export declare function fetchWeather(coords: Coords, timeoutMs?: number): Promise<Weather | null>;
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
3
- * All network helpers return null on failure so the receptionist can degrade
4
- * to a time-only mode without erroring.
2
+ * Keyless weather + geolocation via open-meteo, with an optional ipapi.co IP
3
+ * fallback. Every call is time- and size-bounded, validates the (untrusted)
4
+ * response, and returns null on failure so the receptionist can degrade to a
5
+ * time-only mode without erroring.
5
6
  */
7
+ import { asRecord, fetchJson, finiteNumber } from '../../core/http.js';
8
+ import { sanitizeText } from '../../core/text.js';
6
9
  // WMO weather-interpretation codes → human text + a glyph keyword the
7
10
  // welcome-sign renderer understands (matched by /sun|clear|partly|rain|snow|storm|fog/).
8
11
  const WMO = {
@@ -39,65 +42,56 @@ const WMO = {
39
42
  export function describeWmo(code) {
40
43
  return WMO[code] ?? { text: 'Unknown', icon: 'cloud' };
41
44
  }
42
- async function fetchJson(url, timeoutMs) {
43
- const controller = new AbortController();
44
- const timer = setTimeout(() => controller.abort(), timeoutMs);
45
- try {
46
- const res = await fetch(url, {
47
- signal: controller.signal,
48
- headers: { 'User-Agent': 'nocville-npc' },
49
- });
50
- if (!res.ok)
51
- return null;
52
- return (await res.json());
53
- }
54
- catch {
55
- return null;
56
- }
57
- finally {
58
- clearTimeout(timer);
59
- }
45
+ /** A finite, in-range latitude/longitude pair. */
46
+ export function validCoords(lat, lon) {
47
+ const la = finiteNumber(lat);
48
+ const lo = finiteNumber(lon);
49
+ return la !== null && lo !== null && Math.abs(la) <= 90 && Math.abs(lo) <= 180;
60
50
  }
61
- /** Resolve a place name to coordinates via the open-meteo geocoding API. */
62
- export async function geocodeLocation(name, timeoutMs = 5000) {
63
- const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1`;
64
- const res = await fetchJson(url, timeoutMs);
65
- const first = res?.['results']?.[0];
66
- if (!first || typeof first['latitude'] !== 'number')
51
+ function coordsFrom(rec, labelKey) {
52
+ if (!validCoords(rec['latitude'], rec['longitude']))
67
53
  return null;
54
+ const label = rec[labelKey];
68
55
  return {
69
- lat: first['latitude'],
70
- lon: first['longitude'],
71
- label: first['name'] ?? name,
56
+ lat: rec['latitude'],
57
+ lon: rec['longitude'],
58
+ label: typeof label === 'string' ? sanitizeText(label, 60) : undefined,
72
59
  };
73
60
  }
74
- /** Best-effort IP geolocation via ipapi.co (keyless). */
61
+ /** Resolve a place name to coordinates via the open-meteo geocoding API. */
62
+ export async function geocodeLocation(name, timeoutMs = 5000) {
63
+ const url = 'https://geocoding-api.open-meteo.com/v1/search' +
64
+ `?name=${encodeURIComponent(name.slice(0, 100))}&count=1`;
65
+ const results = asRecord(await fetchJson(url, { timeoutMs }))?.['results'];
66
+ const first = Array.isArray(results) ? asRecord(results[0]) : null;
67
+ return first ? coordsFrom(first, 'name') : null;
68
+ }
69
+ /**
70
+ * Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
71
+ * public IP to a third party, so the receptionist only calls it when no place
72
+ * was configured and the user hasn't opted out. Only the coordinates are used;
73
+ * nothing from the response is pushed to Nocville.
74
+ */
75
75
  export async function autoGeo(timeoutMs = 5000) {
76
- const res = await fetchJson('https://ipapi.co/json/', timeoutMs);
77
- if (!res || typeof res['latitude'] !== 'number')
78
- return null;
79
- return {
80
- lat: res['latitude'],
81
- lon: res['longitude'],
82
- label: res['city'],
83
- };
76
+ const res = asRecord(await fetchJson('https://ipapi.co/json/', { timeoutMs }));
77
+ return res ? coordsFrom(res, 'city') : null;
84
78
  }
85
79
  /** Fetch current temperature, weather code, and wind for coordinates. */
86
80
  export async function fetchWeather(coords, timeoutMs = 5000) {
87
81
  const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}` +
88
82
  `&longitude=${coords.lon}&current=temperature_2m,weather_code,wind_speed_10m`;
89
- const res = await fetchJson(url, timeoutMs);
90
- const cur = res?.['current'];
91
- if (!cur || typeof cur['temperature_2m'] !== 'number')
83
+ const cur = asRecord(asRecord(await fetchJson(url, { timeoutMs }))?.['current']);
84
+ const temp = finiteNumber(cur?.['temperature_2m']);
85
+ if (!cur || temp === null || Math.abs(temp) > 100)
92
86
  return null;
93
- const code = Number(cur['weather_code'] ?? 0);
87
+ const code = finiteNumber(cur['weather_code']) ?? -1;
94
88
  const { text, icon } = describeWmo(code);
95
89
  return {
96
- tempC: Math.round(cur['temperature_2m']),
90
+ tempC: Math.round(temp),
97
91
  code,
98
92
  text,
99
93
  icon,
100
- windKph: Math.round(Number(cur['wind_speed_10m'] ?? 0)),
94
+ windKph: Math.round(Math.max(0, finiteNumber(cur['wind_speed_10m']) ?? 0)),
101
95
  };
102
96
  }
103
97
  /** Convert Celsius to Fahrenheit, rounded. */
@@ -0,0 +1,12 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ import type { PollingNpcConfig } from '../../core/polling-npc.js';
3
+ import type { CollectedMetrics, Archetype, BehaviorHints } from '../../core/types.js';
4
+ export declare class SecurityNPC extends PollingNPC {
5
+ constructor(config: PollingNpcConfig);
6
+ get name(): string;
7
+ protected get defaultDisplayName(): string;
8
+ get archetype(): Archetype;
9
+ get behavior(): BehaviorHints;
10
+ protected collect(): Promise<CollectedMetrics>;
11
+ }
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ export class SecurityNPC extends PollingNPC {
3
+ constructor(config) {
4
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
5
+ }
6
+ get name() {
7
+ return 'security';
8
+ }
9
+ get defaultDisplayName() {
10
+ return 'Security';
11
+ }
12
+ get archetype() {
13
+ return 'custom';
14
+ }
15
+ get behavior() {
16
+ return { role: 'security', activity: 'idle', idlePose: 'stand' };
17
+ }
18
+ async collect() {
19
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
20
+ }
21
+ }
22
+ //# sourceMappingURL=index.js.map
@@ -34,11 +34,23 @@ export declare function createCpuSampler(readCpus?: () => Array<{
34
34
  times: CpuTimes;
35
35
  }>, readLoad?: () => number): CpuSampler;
36
36
  /**
37
- * Collect memory usage from OS.
37
+ * Parse `vm_stat` output into reclaimable ("available") bytes: free +
38
+ * inactive + speculative pages, the same estimate psutil uses on macOS.
39
+ * Returns null when the output is unrecognised.
38
40
  */
39
- export declare function collectMemory(): MemMetrics;
41
+ export declare function parseVmStat(stdout: string): number | null;
40
42
  /**
41
- * Collect disk usage via fs.statfs (Node 18.15+).
43
+ * Bytes of memory available to new work. On macOS `os.freemem()` counts only
44
+ * never-used pages (the file cache is "used"), which reads ~95% full on any
45
+ * healthy Mac, so the reclaimable estimate comes from `vm_stat` instead. Linux
46
+ * (`MemAvailable`) and Windows report a meaningful value through Node.
47
+ */
48
+ export declare function readAvailableMemory(): Promise<number>;
49
+ /** Memory usage from the total and the available byte count. */
50
+ export declare function collectMemory(availableBytes?: number, totalBytes?: number): MemMetrics;
51
+ /**
52
+ * Disk usage via fs.statfs, computed like `df`: used / (used + available to
53
+ * unprivileged users), so blocks reserved for root don't hide a full disk.
42
54
  */
43
55
  export declare function collectDisk(path: string): Promise<DiskMetrics>;
44
56
  export {};
@@ -1,5 +1,8 @@
1
1
  import os from 'node:os';
2
2
  import { promises as fsp } from 'node:fs';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
3
6
  function aggregate(cpus) {
4
7
  let idle = 0;
5
8
  let total = 0;
@@ -10,6 +13,12 @@ function aggregate(cpus) {
10
13
  }
11
14
  return { idle, total };
12
15
  }
16
+ function gb(bytes) {
17
+ return Math.round((bytes / 1024 ** 3) * 100) / 100;
18
+ }
19
+ function percentOf(part, whole) {
20
+ return whole > 0 ? Math.min(100, Math.max(0, Math.round((part / whole) * 100))) : 0;
21
+ }
13
22
  /**
14
23
  * Stateful CPU sampler that computes usage from the delta between consecutive
15
24
  * `os.cpus()` snapshots — no blocking sleep. The first sample reports usage
@@ -27,34 +36,72 @@ export function createCpuSampler(readCpus = () => os.cpus(), readLoad = () => os
27
36
  const idleDiff = cur.idle - base.idle;
28
37
  const totalDiff = cur.total - base.total;
29
38
  prev = cur;
30
- const percent = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 100) : 0;
39
+ const percent = totalDiff > 0 ? percentOf(totalDiff - idleDiff, totalDiff) : 0;
31
40
  return { percent, loadAvg1m };
32
41
  },
33
42
  };
34
43
  }
35
44
  /**
36
- * Collect memory usage from OS.
45
+ * Parse `vm_stat` output into reclaimable ("available") bytes: free +
46
+ * inactive + speculative pages, the same estimate psutil uses on macOS.
47
+ * Returns null when the output is unrecognised.
48
+ */
49
+ export function parseVmStat(stdout) {
50
+ const pageSize = Number(stdout.match(/page size of (\d+) bytes/)?.[1]);
51
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
52
+ return null;
53
+ const pages = (label) => {
54
+ const m = stdout.match(new RegExp(`^Pages ${label}:\\s+(\\d+)`, 'm'));
55
+ return m ? Number(m[1]) : null;
56
+ };
57
+ const free = pages('free');
58
+ const inactive = pages('inactive');
59
+ if (free === null || inactive === null)
60
+ return null;
61
+ return (free + inactive + (pages('speculative') ?? 0)) * pageSize;
62
+ }
63
+ /**
64
+ * Bytes of memory available to new work. On macOS `os.freemem()` counts only
65
+ * never-used pages (the file cache is "used"), which reads ~95% full on any
66
+ * healthy Mac, so the reclaimable estimate comes from `vm_stat` instead. Linux
67
+ * (`MemAvailable`) and Windows report a meaningful value through Node.
37
68
  */
38
- export function collectMemory() {
39
- const totalBytes = os.totalmem();
40
- const freeBytes = os.freemem();
41
- const usedBytes = totalBytes - freeBytes;
42
- const totalGb = Math.round((totalBytes / 1024 ** 3) * 100) / 100;
43
- const usedGb = Math.round((usedBytes / 1024 ** 3) * 100) / 100;
44
- const percent = Math.round((usedBytes / totalBytes) * 100);
45
- return { percent, usedGb, totalGb };
69
+ export async function readAvailableMemory() {
70
+ if (process.platform === 'darwin') {
71
+ try {
72
+ const { stdout } = await execFileAsync('vm_stat', [], { timeout: 2000 });
73
+ const available = parseVmStat(stdout);
74
+ if (available !== null)
75
+ return Math.min(available, os.totalmem());
76
+ }
77
+ catch {
78
+ // Fall through to Node's figure.
79
+ }
80
+ }
81
+ return os.freemem();
82
+ }
83
+ /** Memory usage from the total and the available byte count. */
84
+ export function collectMemory(availableBytes = os.freemem(), totalBytes = os.totalmem()) {
85
+ const usedBytes = Math.max(0, totalBytes - availableBytes);
86
+ return {
87
+ percent: percentOf(usedBytes, totalBytes),
88
+ usedGb: gb(usedBytes),
89
+ totalGb: gb(totalBytes),
90
+ };
46
91
  }
47
92
  /**
48
- * Collect disk usage via fs.statfs (Node 18.15+).
93
+ * Disk usage via fs.statfs, computed like `df`: used / (used + available to
94
+ * unprivileged users), so blocks reserved for root don't hide a full disk.
49
95
  */
50
96
  export async function collectDisk(path) {
51
97
  const stats = await fsp.statfs(path);
52
98
  const totalBytes = stats.blocks * stats.bsize;
53
- const freeBytes = stats.bavail * stats.bsize;
54
- const usedBytes = totalBytes - freeBytes;
55
- const totalGb = Math.round((totalBytes / 1024 ** 3) * 100) / 100;
56
- const usedGb = Math.round((usedBytes / 1024 ** 3) * 100) / 100;
57
- const percent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
58
- return { percent, usedGb, totalGb };
99
+ const usedBytes = (stats.blocks - stats.bfree) * stats.bsize;
100
+ const availBytes = stats.bavail * stats.bsize;
101
+ return {
102
+ percent: percentOf(usedBytes, usedBytes + availBytes),
103
+ usedGb: gb(usedBytes),
104
+ totalGb: gb(totalBytes),
105
+ };
59
106
  }
60
107
  //# sourceMappingURL=collectors.js.map
@@ -3,5 +3,5 @@ export declare const DEFAULTS: {
3
3
  pollInterval: number;
4
4
  diskPath: string;
5
5
  };
6
- export declare const DEFAULT_THRESHOLDS: Record<string, ThresholdConfig>;
6
+ export declare const DEFAULT_THRESHOLDS: Record<'cpu' | 'mem' | 'disk', ThresholdConfig>;
7
7
  //# sourceMappingURL=defaults.d.ts.map
@@ -15,13 +15,14 @@ export declare class SystemMonitorNPC extends PollingNPC {
15
15
  private cpuSampler;
16
16
  private cpuHistory;
17
17
  private memHistory;
18
+ private diskErrorLogged;
18
19
  constructor(config: SystemMonitorConfig);
19
20
  get name(): string;
20
21
  protected get defaultDisplayName(): string;
21
22
  get archetype(): Archetype;
22
- get heartbeatSeconds(): number;
23
23
  get behavior(): BehaviorHints;
24
24
  get companion(): CompanionDecl;
25
25
  protected collect(): Promise<CollectedMetrics>;
26
+ private readDisk;
26
27
  }
27
28
  //# sourceMappingURL=index.d.ts.map
@@ -1,7 +1,8 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { evaluateThreshold, worstStatus } from '../../core/thresholds.js';
2
+ import { evaluateThreshold, worstStatus, moodForStatus, thresholdProblem, } from '../../core/thresholds.js';
3
3
  import { statusColor, pushHistory } from '../../core/screen.js';
4
- import { createCpuSampler, collectMemory, collectDisk } from './collectors.js';
4
+ import { NpcConfigError } from '../../core/errors.js';
5
+ import { createCpuSampler, collectMemory, collectDisk, readAvailableMemory } from './collectors.js';
5
6
  import { DEFAULTS, DEFAULT_THRESHOLDS } from './defaults.js';
6
7
  export class SystemMonitorNPC extends PollingNPC {
7
8
  diskPath;
@@ -11,15 +12,21 @@ export class SystemMonitorNPC extends PollingNPC {
11
12
  cpuSampler = createCpuSampler();
12
13
  cpuHistory = [];
13
14
  memHistory = [];
15
+ diskErrorLogged = false;
14
16
  constructor(config) {
15
17
  super({
16
18
  ...config,
17
19
  pollInterval: config.pollInterval ?? DEFAULTS.pollInterval,
18
20
  });
19
21
  this.diskPath = config.diskPath ?? DEFAULTS.diskPath;
20
- this.cpuThreshold = config.cpuThreshold ?? DEFAULT_THRESHOLDS['cpu'];
21
- this.memThreshold = config.memThreshold ?? DEFAULT_THRESHOLDS['mem'];
22
- this.diskThreshold = config.diskThreshold ?? DEFAULT_THRESHOLDS['disk'];
22
+ this.cpuThreshold = config.cpuThreshold ?? DEFAULT_THRESHOLDS.cpu;
23
+ this.memThreshold = config.memThreshold ?? DEFAULT_THRESHOLDS.mem;
24
+ this.diskThreshold = config.diskThreshold ?? DEFAULT_THRESHOLDS.disk;
25
+ const problem = thresholdProblem('CPU', this.cpuThreshold) ??
26
+ thresholdProblem('Memory', this.memThreshold) ??
27
+ thresholdProblem('Disk', this.diskThreshold);
28
+ if (problem)
29
+ throw new NpcConfigError(problem);
23
30
  }
24
31
  get name() {
25
32
  return 'system-monitor';
@@ -30,9 +37,6 @@ export class SystemMonitorNPC extends PollingNPC {
30
37
  get archetype() {
31
38
  return 'service';
32
39
  }
33
- get heartbeatSeconds() {
34
- return 30;
35
- }
36
40
  get behavior() {
37
41
  return { idle: 'wander' };
38
42
  }
@@ -46,19 +50,20 @@ export class SystemMonitorNPC extends PollingNPC {
46
50
  }
47
51
  async collect() {
48
52
  const cpu = this.cpuSampler.sample();
49
- const mem = collectMemory();
50
- const disk = await collectDisk(this.diskPath);
53
+ const mem = collectMemory(await readAvailableMemory());
54
+ const disk = await this.readDisk();
51
55
  const cpuStatus = evaluateThreshold(cpu.percent, this.cpuThreshold);
52
56
  const memStatus = evaluateThreshold(mem.percent, this.memThreshold);
53
- const diskStatus = evaluateThreshold(disk.percent, this.diskThreshold);
57
+ // An unreadable disk (unmounted volume, bad --disk-path) is a warning, not a crash.
58
+ const diskStatus = disk
59
+ ? evaluateThreshold(disk.percent, this.diskThreshold)
60
+ : 'warning';
54
61
  const status = worstStatus([cpuStatus, memStatus, diskStatus]);
55
- const mood = status === 'critical'
56
- ? 'stressed'
57
- : status === 'warning'
58
- ? 'concerned'
59
- : 'happy';
60
62
  pushHistory(this.cpuHistory, cpu.percent);
61
63
  pushHistory(this.memHistory, mem.percent);
64
+ const diskText = disk ? `${disk.percent}%` : 'n/a';
65
+ // Windows has no load average (os.loadavg() is always zeros there).
66
+ const loadText = process.platform === 'win32' ? 'n/a' : cpu.loadAvg1m.toFixed(2);
62
67
  const screenData = {
63
68
  layout: 'multi-metric',
64
69
  values: [
@@ -74,28 +79,47 @@ export class SystemMonitorNPC extends PollingNPC {
74
79
  color: statusColor(memStatus),
75
80
  sparkline: [...this.memHistory],
76
81
  },
77
- { label: 'DISK', value: `${disk.percent}%`, color: statusColor(diskStatus) },
78
- { label: 'LOAD', value: cpu.loadAvg1m.toFixed(2), color: 'gray' },
82
+ { label: 'DISK', value: diskText, color: statusColor(diskStatus) },
83
+ { label: 'LOAD', value: loadText, color: 'gray' },
79
84
  ],
80
85
  };
86
+ const metrics = {
87
+ cpu_percent: cpu.percent,
88
+ mem_percent: mem.percent,
89
+ mem_used_gb: mem.usedGb,
90
+ mem_total_gb: mem.totalGb,
91
+ load_avg_1m: cpu.loadAvg1m,
92
+ };
93
+ if (disk) {
94
+ metrics['disk_percent'] = disk.percent;
95
+ metrics['disk_used_gb'] = disk.usedGb;
96
+ metrics['disk_total_gb'] = disk.totalGb;
97
+ }
81
98
  return {
82
99
  status,
83
- mood,
84
- message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk.percent}%`,
100
+ mood: moodForStatus(status),
101
+ message: `CPU: ${cpu.percent}% | Mem: ${mem.percent}% | Disk: ${disk ? diskText : 'unavailable'}`,
85
102
  screenData,
86
103
  // When strained, go find a desk and get to work; otherwise wander.
87
104
  behavior: status !== 'healthy' ? { busy: 'seek-desk' } : undefined,
88
- metrics: {
89
- cpu_percent: cpu.percent,
90
- mem_percent: mem.percent,
91
- mem_used_gb: mem.usedGb,
92
- mem_total_gb: mem.totalGb,
93
- disk_percent: disk.percent,
94
- disk_used_gb: disk.usedGb,
95
- disk_total_gb: disk.totalGb,
96
- load_avg_1m: cpu.loadAvg1m,
97
- },
105
+ metrics,
98
106
  };
99
107
  }
108
+ async readDisk() {
109
+ try {
110
+ const disk = await collectDisk(this.diskPath);
111
+ this.diskErrorLogged = false;
112
+ return disk;
113
+ }
114
+ catch (err) {
115
+ if (!this.diskErrorLogged) {
116
+ this.diskErrorLogged = true;
117
+ this.logger.warn(`Cannot read disk usage for ${this.diskPath}`, {
118
+ error: err instanceof Error ? err.message : String(err),
119
+ });
120
+ }
121
+ return null;
122
+ }
123
+ }
100
124
  }
101
125
  //# sourceMappingURL=index.js.map
@@ -5,12 +5,12 @@ export interface BasicCheck {
5
5
  ok: boolean;
6
6
  timedOut: boolean;
7
7
  }
8
- /** Extract the <title> text from an HTML string (empty if none). */
8
+ /** Extract the <title> text from (untrusted) HTML: entities decoded, sanitized, ≤80 chars. */
9
9
  export declare function extractTitle(html: string): string;
10
10
  /** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
11
11
  export declare function statusFromHttp(code: number, timedOut?: boolean): Status;
12
12
  /** Fetch a page and extract HTTP status + <title> (basic, no browser). */
13
13
  export declare function basicCheck(url: string, timeoutMs?: number): Promise<BasicCheck>;
14
- /** Best-effort hostname for a URL, falling back to the raw string. */
14
+ /** Hostname of a URL for labels and service ids (never credentials or path). */
15
15
  export declare function hostnameOf(url: string): string;
16
16
  //# sourceMappingURL=basic-mode.d.ts.map