@nocville/npc 0.2.2 → 0.2.3

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 (96) hide show
  1. package/README.md +23 -5
  2. package/dist/cjs/cli.js +55 -34
  3. package/dist/cjs/core/base-npc.js +8 -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 +10 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  14. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  15. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  16. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  17. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  18. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  19. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  20. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  21. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  22. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  23. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  24. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  25. package/dist/cjs/npcs/receptionist/index.js +45 -19
  26. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  27. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  28. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  29. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  30. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  31. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  32. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  33. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  34. package/dist/cli.js +56 -35
  35. package/dist/core/base-npc.d.ts +5 -0
  36. package/dist/core/base-npc.js +8 -0
  37. package/dist/core/config.d.ts +5 -0
  38. package/dist/core/config.js +18 -2
  39. package/dist/core/errors.d.ts +11 -0
  40. package/dist/core/errors.js +18 -0
  41. package/dist/core/http.d.ts +52 -0
  42. package/dist/core/http.js +111 -0
  43. package/dist/core/pixel-art.d.ts +38 -0
  44. package/dist/core/pixel-art.js +105 -0
  45. package/dist/core/polling-npc.d.ts +22 -1
  46. package/dist/core/polling-npc.js +98 -13
  47. package/dist/core/text.d.ts +6 -0
  48. package/dist/core/text.js +12 -0
  49. package/dist/core/thresholds.d.ts +9 -1
  50. package/dist/core/thresholds.js +27 -0
  51. package/dist/index.d.ts +5 -2
  52. package/dist/index.js +4 -2
  53. package/dist/npcs/agent-common/session.js +1 -0
  54. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  55. package/dist/npcs/github-watcher/defaults.js +7 -1
  56. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  57. package/dist/npcs/github-watcher/github-api.js +80 -45
  58. package/dist/npcs/github-watcher/index.d.ts +6 -0
  59. package/dist/npcs/github-watcher/index.js +83 -37
  60. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  61. package/dist/npcs/internet-connectivity/checks.js +70 -67
  62. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  63. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  64. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  65. package/dist/npcs/internet-connectivity/index.js +135 -133
  66. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  67. package/dist/npcs/log-watcher/buffer.js +58 -0
  68. package/dist/npcs/log-watcher/index.d.ts +22 -21
  69. package/dist/npcs/log-watcher/index.js +112 -106
  70. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  71. package/dist/npcs/log-watcher/tail.js +101 -54
  72. package/dist/npcs/process-watcher/index.d.ts +1 -1
  73. package/dist/npcs/process-watcher/index.js +62 -52
  74. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  75. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  76. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  77. package/dist/npcs/receptionist/defaults.js +4 -0
  78. package/dist/npcs/receptionist/index.d.ts +12 -2
  79. package/dist/npcs/receptionist/index.js +46 -20
  80. package/dist/npcs/receptionist/weather.d.ts +8 -6
  81. package/dist/npcs/receptionist/weather.js +39 -45
  82. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  83. package/dist/npcs/system-monitor/collectors.js +64 -17
  84. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  85. package/dist/npcs/system-monitor/index.d.ts +2 -1
  86. package/dist/npcs/system-monitor/index.js +54 -30
  87. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  88. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  89. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  90. package/dist/npcs/web-page-watcher/capture.js +21 -1
  91. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  92. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  93. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  94. package/dist/npcs/web-page-watcher/index.js +121 -70
  95. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  96. package/package.json +2 -2
@@ -1,10 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectTransitions = detectTransitions;
4
+ exports.parsePsOutput = parsePsOutput;
5
+ exports.matchesName = matchesName;
6
+ exports.listProcesses = listProcesses;
7
+ exports.matchProcesses = matchProcesses;
4
8
  exports.scanProcesses = scanProcesses;
5
9
  const node_child_process_1 = require("node:child_process");
6
10
  const node_util_1 = require("node:util");
7
11
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
12
+ /** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
13
+ const PS_TIMEOUT_MS = 5000;
14
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
15
+ /** Linux truncates `comm` to 15 characters (TASK_COMM_LEN - 1). */
16
+ const LINUX_COMM_LEN = 15;
8
17
  /**
9
18
  * Compare the previous and current running-process sets and return speech
10
19
  * strings for any transitions: "<name> died!" when a process disappears and
@@ -23,92 +32,77 @@ function detectTransitions(prev, curr) {
23
32
  return messages;
24
33
  }
25
34
  /**
26
- * Parse the output of `ps` into structured entries.
27
- * Expects columns: PID, %CPU, RSS (KB), COMMAND
35
+ * Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
36
+ * of the line, so macOS paths with spaces ("Google Chrome") survive intact.
28
37
  */
29
38
  function parsePsOutput(stdout) {
30
- const lines = stdout.trim().split('\n');
31
- // Skip the header line
32
39
  const entries = [];
33
- for (let i = 1; i < lines.length; i++) {
34
- const line = lines[i].trim();
35
- if (!line)
40
+ for (const line of stdout.split('\n')) {
41
+ const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
42
+ if (!m)
36
43
  continue;
37
- // Fields are whitespace-separated: PID, %CPU, RSS, COMMAND (may contain spaces)
38
- const parts = line.split(/\s+/);
39
- if (parts.length < 4)
40
- continue;
41
- const pid = parseInt(parts[0], 10);
42
- const cpu = parseFloat(parts[1]);
43
- const memKb = parseInt(parts[2], 10);
44
- // COMMAND is everything from the 4th column onward
45
- const comm = parts.slice(3).join(' ');
46
- if (Number.isNaN(pid) || Number.isNaN(cpu) || Number.isNaN(memKb))
47
- continue;
48
- entries.push({ pid, cpu, memKb, comm });
44
+ entries.push({
45
+ pid: Number(m[1]),
46
+ cpu: Number(m[2]),
47
+ memKb: Number(m[3]),
48
+ comm: m[4],
49
+ });
49
50
  }
50
51
  return entries;
51
52
  }
52
53
  /**
53
- * Match a process name against a ps COMMAND column.
54
- * Matches if the basename of the command path equals the search name,
55
- * or if the command contains the search name.
54
+ * Does a ps COMMAND entry match a watched name? Case-insensitive on the
55
+ * executable's basename:
56
+ * - exact: `nginx` matches `/usr/sbin/nginx`;
57
+ * - version suffix: `python` matches `python3` and `python3.11`;
58
+ * - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
59
+ * - a name containing `/` must equal the full path (macOS reports paths).
60
+ * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
56
61
  */
57
62
  function matchesName(comm, searchName) {
58
- // Get the basename of the command (strip path and arguments)
59
- const basename = comm.split('/').pop()?.split(/\s/)[0] ?? '';
60
- if (basename === searchName)
63
+ if (searchName.includes('/'))
64
+ return comm === searchName;
65
+ const base = (comm.split('/').pop() ?? comm).toLowerCase();
66
+ const name = searchName.toLowerCase();
67
+ if (base === name)
61
68
  return true;
62
- // Also check if the full command string contains the name
63
- // (handles cases like "python my-script.py" when searching for "python")
64
- return comm.toLowerCase().includes(searchName.toLowerCase());
69
+ if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
70
+ return true;
71
+ return base.startsWith(name) && /^\d[\d.]*$/.test(base.slice(name.length));
65
72
  }
66
- /**
67
- * Scan for processes by name and return their status and resource usage.
68
- * Works on macOS and Linux by using `ps` to list all processes.
69
- */
70
- async function scanProcesses(names) {
71
- let psArgs;
72
- if (process.platform === 'darwin') {
73
- // macOS: -e selects all processes, -o specifies output columns
74
- psArgs = ['-eo', 'pid,pcpu,rss,comm'];
75
- }
76
- else {
77
- // Linux: -eo with similar columns
78
- psArgs = ['-eo', 'pid,pcpu,rss,comm'];
79
- }
80
- let entries;
81
- try {
82
- const { stdout } = await execFileAsync('ps', psArgs);
83
- entries = parsePsOutput(stdout);
84
- }
85
- catch {
86
- // If ps fails, report all processes as unknown / not running
87
- return names.map((name) => ({ name, running: false }));
88
- }
89
- const results = [];
90
- for (const name of names) {
91
- // Find all matching entries for this process name
73
+ /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
74
+ async function listProcesses() {
75
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
76
+ timeout: PS_TIMEOUT_MS,
77
+ maxBuffer: PS_MAX_BUFFER,
78
+ // Some locales print %CPU with a decimal comma ("1,5"); force "1.5".
79
+ env: { ...process.env, LC_ALL: 'C' },
80
+ });
81
+ return parsePsOutput(stdout);
82
+ }
83
+ /** Summarise each watched name against a process list (CPU and memory summed per name). */
84
+ function matchProcesses(names, entries) {
85
+ return names.map((name) => {
92
86
  const matching = entries.filter((e) => matchesName(e.comm, name));
93
- if (matching.length === 0) {
94
- results.push({ name, running: false });
95
- continue;
96
- }
97
- // Aggregate: take the first matching PID, sum CPU and memory across all instances
98
- let totalCpu = 0;
99
- let totalMemKb = 0;
100
- for (const entry of matching) {
101
- totalCpu += entry.cpu;
102
- totalMemKb += entry.memKb;
103
- }
104
- results.push({
87
+ if (matching.length === 0)
88
+ return { name, running: false };
89
+ const totalCpu = matching.reduce((sum, e) => sum + e.cpu, 0);
90
+ const totalMemKb = matching.reduce((sum, e) => sum + e.memKb, 0);
91
+ return {
105
92
  name,
106
93
  running: true,
107
94
  pid: matching[0].pid,
95
+ count: matching.length,
108
96
  cpuPercent: Math.round(totalCpu * 100) / 100,
109
97
  memMb: Math.round((totalMemKb / 1024) * 100) / 100,
110
- });
111
- }
112
- return results;
98
+ };
99
+ });
100
+ }
101
+ /**
102
+ * Scan for processes by name and return their status and resource usage.
103
+ * Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
104
+ */
105
+ async function scanProcesses(names) {
106
+ return matchProcesses(names, await listProcesses());
113
107
  }
114
108
  //# sourceMappingURL=process-scanner.js.map
@@ -4,6 +4,10 @@ exports.DEFAULTS = void 0;
4
4
  exports.DEFAULTS = {
5
5
  pollInterval: 60000, // 60s clock tick
6
6
  weatherRefreshMs: 15 * 60 * 1000, // refresh weather every 15 minutes
7
+ /** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
8
+ weatherMaxAgeMs: 2 * 60 * 60 * 1000,
9
+ /** Wait this long before retrying a failed geocode / IP geo / weather call. */
10
+ retryBackoffMs: 5 * 60 * 1000,
7
11
  fetchTimeoutMs: 5000,
8
12
  };
9
13
  //# sourceMappingURL=defaults.js.map
@@ -2,24 +2,36 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ReceptionistNPC = void 0;
4
4
  const polling_npc_js_1 = require("../../core/polling-npc.js");
5
+ const errors_js_1 = require("../../core/errors.js");
5
6
  const defaults_js_1 = require("./defaults.js");
6
7
  const weather_js_1 = require("./weather.js");
7
8
  class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
8
9
  explicitCoords;
9
10
  location;
10
11
  fahrenheit;
12
+ ipGeo;
11
13
  coords = null;
12
- geoAttempted = false;
14
+ /** Earliest time to retry geocoding / IP geo after a failure. */
15
+ nextGeoAttempt = 0;
13
16
  weather = null;
14
- lastWeatherFetch = 0;
17
+ weatherFetchedAt = 0;
18
+ /** Earliest time to retry the weather after a failure. */
19
+ nextWeatherAttempt = 0;
15
20
  constructor(config) {
16
21
  super({ ...config, pollInterval: config.pollInterval ?? defaults_js_1.DEFAULTS.pollInterval });
22
+ if ((config.lat === undefined) !== (config.lon === undefined)) {
23
+ throw new errors_js_1.NpcConfigError('--lat and --lon must be given together');
24
+ }
25
+ if (config.lat !== undefined && !(0, weather_js_1.validCoords)(config.lat, config.lon)) {
26
+ throw new errors_js_1.NpcConfigError('--lat must be within ±90 and --lon within ±180');
27
+ }
17
28
  this.explicitCoords =
18
29
  config.lat !== undefined && config.lon !== undefined
19
30
  ? { lat: config.lat, lon: config.lon }
20
31
  : null;
21
- this.location = config.location;
32
+ this.location = config.location?.trim() || undefined;
22
33
  this.fahrenheit = config.fahrenheit ?? false;
34
+ this.ipGeo = config.ipGeo ?? true;
23
35
  }
24
36
  get name() {
25
37
  return 'receptionist';
@@ -78,41 +90,55 @@ class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
78
90
  };
79
91
  }
80
92
  async refreshWeather() {
81
- if (!this.coords) {
82
- await this.resolveCoords();
83
- }
93
+ const now = Date.now();
84
94
  if (!this.coords)
95
+ await this.resolveCoords(now);
96
+ // Don't show a reading that has gone stale through repeated failures.
97
+ if (this.weather && now - this.weatherFetchedAt > defaults_js_1.DEFAULTS.weatherMaxAgeMs) {
98
+ this.weather = null;
99
+ }
100
+ if (!this.coords || now < this.nextWeatherAttempt)
85
101
  return;
86
- const now = Date.now();
87
- if (this.weather && now - this.lastWeatherFetch < defaults_js_1.DEFAULTS.weatherRefreshMs)
102
+ if (this.weather && now - this.weatherFetchedAt < defaults_js_1.DEFAULTS.weatherRefreshMs)
88
103
  return;
89
104
  const w = await (0, weather_js_1.fetchWeather)(this.coords, defaults_js_1.DEFAULTS.fetchTimeoutMs);
90
105
  if (w) {
91
106
  this.weather = w;
92
- this.lastWeatherFetch = now;
107
+ this.weatherFetchedAt = now;
108
+ }
109
+ else {
110
+ this.nextWeatherAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
93
111
  }
94
112
  }
95
- async resolveCoords() {
113
+ async resolveCoords(now) {
96
114
  if (this.explicitCoords) {
97
115
  this.coords = this.explicitCoords;
98
116
  return;
99
117
  }
100
- // Only attempt geocoding/geo-IP once per (failed) resolution to avoid
101
- // hammering the keyless endpoints every poll.
102
- if (this.geoAttempted)
118
+ // Back off after a failure rather than hitting the keyless endpoints every tick.
119
+ if (now < this.nextGeoAttempt)
103
120
  return;
104
- this.geoAttempted = true;
121
+ this.nextGeoAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
105
122
  if (this.location) {
106
123
  this.coords = await (0, weather_js_1.geocodeLocation)(this.location, defaults_js_1.DEFAULTS.fetchTimeoutMs);
107
124
  if (this.coords)
108
125
  return;
109
- this.logger.warn(`Could not geocode "${this.location}", falling back to IP geo`);
126
+ this.logger.warn(`Could not geocode "${this.location}"` + (this.ipGeo ? ', falling back to IP geo' : ''));
127
+ }
128
+ if (!this.ipGeo) {
129
+ if (!this.location) {
130
+ this.nextGeoAttempt = Infinity; // nothing to resolve, ever
131
+ this.logger.info('No location set and IP geolocation disabled: time-only mode');
132
+ }
133
+ return;
110
134
  }
111
135
  this.coords = await (0, weather_js_1.autoGeo)(defaults_js_1.DEFAULTS.fetchTimeoutMs);
112
- if (!this.coords) {
113
- this.logger.warn('Weather geo unavailable — running in time-only mode');
114
- // Allow a later retry.
115
- this.geoAttempted = false;
136
+ if (this.coords) {
137
+ this.logger.info("Weather location estimated from this machine's IP via ipapi.co " +
138
+ '(set --location or --lat/--lon, or pass --no-ip-geo to disable)');
139
+ }
140
+ else {
141
+ this.logger.warn('Weather geo unavailable — running in time-only mode for now');
116
142
  }
117
143
  }
118
144
  }
@@ -1,16 +1,20 @@
1
1
  "use strict";
2
- /**
3
- * Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
4
- * All network helpers return null on failure so the receptionist can degrade
5
- * to a time-only mode without erroring.
6
- */
7
2
  Object.defineProperty(exports, "__esModule", { value: true });
8
3
  exports.describeWmo = describeWmo;
4
+ exports.validCoords = validCoords;
9
5
  exports.geocodeLocation = geocodeLocation;
10
6
  exports.autoGeo = autoGeo;
11
7
  exports.fetchWeather = fetchWeather;
12
8
  exports.toFahrenheit = toFahrenheit;
13
9
  exports.formatClock = formatClock;
10
+ /**
11
+ * Keyless weather + geolocation via open-meteo, with an optional ipapi.co IP
12
+ * fallback. Every call is time- and size-bounded, validates the (untrusted)
13
+ * response, and returns null on failure so the receptionist can degrade to a
14
+ * time-only mode without erroring.
15
+ */
16
+ const http_js_1 = require("../../core/http.js");
17
+ const text_js_1 = require("../../core/text.js");
14
18
  // WMO weather-interpretation codes → human text + a glyph keyword the
15
19
  // welcome-sign renderer understands (matched by /sun|clear|partly|rain|snow|storm|fog/).
16
20
  const WMO = {
@@ -47,65 +51,56 @@ const WMO = {
47
51
  function describeWmo(code) {
48
52
  return WMO[code] ?? { text: 'Unknown', icon: 'cloud' };
49
53
  }
50
- async function fetchJson(url, timeoutMs) {
51
- const controller = new AbortController();
52
- const timer = setTimeout(() => controller.abort(), timeoutMs);
53
- try {
54
- const res = await fetch(url, {
55
- signal: controller.signal,
56
- headers: { 'User-Agent': 'nocville-npc' },
57
- });
58
- if (!res.ok)
59
- return null;
60
- return (await res.json());
61
- }
62
- catch {
63
- return null;
64
- }
65
- finally {
66
- clearTimeout(timer);
67
- }
54
+ /** A finite, in-range latitude/longitude pair. */
55
+ function validCoords(lat, lon) {
56
+ const la = (0, http_js_1.finiteNumber)(lat);
57
+ const lo = (0, http_js_1.finiteNumber)(lon);
58
+ return la !== null && lo !== null && Math.abs(la) <= 90 && Math.abs(lo) <= 180;
68
59
  }
69
- /** Resolve a place name to coordinates via the open-meteo geocoding API. */
70
- async function geocodeLocation(name, timeoutMs = 5000) {
71
- const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1`;
72
- const res = await fetchJson(url, timeoutMs);
73
- const first = res?.['results']?.[0];
74
- if (!first || typeof first['latitude'] !== 'number')
60
+ function coordsFrom(rec, labelKey) {
61
+ if (!validCoords(rec['latitude'], rec['longitude']))
75
62
  return null;
63
+ const label = rec[labelKey];
76
64
  return {
77
- lat: first['latitude'],
78
- lon: first['longitude'],
79
- label: first['name'] ?? name,
65
+ lat: rec['latitude'],
66
+ lon: rec['longitude'],
67
+ label: typeof label === 'string' ? (0, text_js_1.sanitizeText)(label, 60) : undefined,
80
68
  };
81
69
  }
82
- /** Best-effort IP geolocation via ipapi.co (keyless). */
70
+ /** Resolve a place name to coordinates via the open-meteo geocoding API. */
71
+ async function geocodeLocation(name, timeoutMs = 5000) {
72
+ const url = 'https://geocoding-api.open-meteo.com/v1/search' +
73
+ `?name=${encodeURIComponent(name.slice(0, 100))}&count=1`;
74
+ const results = (0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)(url, { timeoutMs }))?.['results'];
75
+ const first = Array.isArray(results) ? (0, http_js_1.asRecord)(results[0]) : null;
76
+ return first ? coordsFrom(first, 'name') : null;
77
+ }
78
+ /**
79
+ * Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
80
+ * public IP to a third party, so the receptionist only calls it when no place
81
+ * was configured and the user hasn't opted out. Only the coordinates are used;
82
+ * nothing from the response is pushed to Nocville.
83
+ */
83
84
  async function autoGeo(timeoutMs = 5000) {
84
- const res = await fetchJson('https://ipapi.co/json/', timeoutMs);
85
- if (!res || typeof res['latitude'] !== 'number')
86
- return null;
87
- return {
88
- lat: res['latitude'],
89
- lon: res['longitude'],
90
- label: res['city'],
91
- };
85
+ const res = (0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)('https://ipapi.co/json/', { timeoutMs }));
86
+ return res ? coordsFrom(res, 'city') : null;
92
87
  }
93
88
  /** Fetch current temperature, weather code, and wind for coordinates. */
94
89
  async function fetchWeather(coords, timeoutMs = 5000) {
95
90
  const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}` +
96
91
  `&longitude=${coords.lon}&current=temperature_2m,weather_code,wind_speed_10m`;
97
- const res = await fetchJson(url, timeoutMs);
98
- const cur = res?.['current'];
99
- if (!cur || typeof cur['temperature_2m'] !== 'number')
92
+ const cur = (0, http_js_1.asRecord)((0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)(url, { timeoutMs }))?.['current']);
93
+ const temp = (0, http_js_1.finiteNumber)(cur?.['temperature_2m']);
94
+ if (!cur || temp === null || Math.abs(temp) > 100)
100
95
  return null;
101
- const code = Number(cur['weather_code'] ?? 0);
96
+ const code = (0, http_js_1.finiteNumber)(cur['weather_code']) ?? -1;
102
97
  const { text, icon } = describeWmo(code);
103
98
  return {
104
- tempC: Math.round(cur['temperature_2m']),
99
+ tempC: Math.round(temp),
105
100
  code,
106
101
  text,
107
102
  icon,
108
- windKph: Math.round(Number(cur['wind_speed_10m'] ?? 0)),
103
+ windKph: Math.round(Math.max(0, (0, http_js_1.finiteNumber)(cur['wind_speed_10m']) ?? 0)),
109
104
  };
110
105
  }
111
106
  /** Convert Celsius to Fahrenheit, rounded. */
@@ -4,10 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createCpuSampler = createCpuSampler;
7
+ exports.parseVmStat = parseVmStat;
8
+ exports.readAvailableMemory = readAvailableMemory;
7
9
  exports.collectMemory = collectMemory;
8
10
  exports.collectDisk = collectDisk;
9
11
  const node_os_1 = __importDefault(require("node:os"));
10
12
  const node_fs_1 = require("node:fs");
13
+ const node_child_process_1 = require("node:child_process");
14
+ const node_util_1 = require("node:util");
15
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
11
16
  function aggregate(cpus) {
12
17
  let idle = 0;
13
18
  let total = 0;
@@ -18,6 +23,12 @@ function aggregate(cpus) {
18
23
  }
19
24
  return { idle, total };
20
25
  }
26
+ function gb(bytes) {
27
+ return Math.round((bytes / 1024 ** 3) * 100) / 100;
28
+ }
29
+ function percentOf(part, whole) {
30
+ return whole > 0 ? Math.min(100, Math.max(0, Math.round((part / whole) * 100))) : 0;
31
+ }
21
32
  /**
22
33
  * Stateful CPU sampler that computes usage from the delta between consecutive
23
34
  * `os.cpus()` snapshots — no blocking sleep. The first sample reports usage
@@ -35,34 +46,72 @@ function createCpuSampler(readCpus = () => node_os_1.default.cpus(), readLoad =
35
46
  const idleDiff = cur.idle - base.idle;
36
47
  const totalDiff = cur.total - base.total;
37
48
  prev = cur;
38
- const percent = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 100) : 0;
49
+ const percent = totalDiff > 0 ? percentOf(totalDiff - idleDiff, totalDiff) : 0;
39
50
  return { percent, loadAvg1m };
40
51
  },
41
52
  };
42
53
  }
43
54
  /**
44
- * Collect memory usage from OS.
55
+ * Parse `vm_stat` output into reclaimable ("available") bytes: free +
56
+ * inactive + speculative pages, the same estimate psutil uses on macOS.
57
+ * Returns null when the output is unrecognised.
58
+ */
59
+ function parseVmStat(stdout) {
60
+ const pageSize = Number(stdout.match(/page size of (\d+) bytes/)?.[1]);
61
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
62
+ return null;
63
+ const pages = (label) => {
64
+ const m = stdout.match(new RegExp(`^Pages ${label}:\\s+(\\d+)`, 'm'));
65
+ return m ? Number(m[1]) : null;
66
+ };
67
+ const free = pages('free');
68
+ const inactive = pages('inactive');
69
+ if (free === null || inactive === null)
70
+ return null;
71
+ return (free + inactive + (pages('speculative') ?? 0)) * pageSize;
72
+ }
73
+ /**
74
+ * Bytes of memory available to new work. On macOS `os.freemem()` counts only
75
+ * never-used pages (the file cache is "used"), which reads ~95% full on any
76
+ * healthy Mac, so the reclaimable estimate comes from `vm_stat` instead. Linux
77
+ * (`MemAvailable`) and Windows report a meaningful value through Node.
45
78
  */
46
- function collectMemory() {
47
- const totalBytes = node_os_1.default.totalmem();
48
- const freeBytes = node_os_1.default.freemem();
49
- const usedBytes = totalBytes - freeBytes;
50
- const totalGb = Math.round((totalBytes / 1024 ** 3) * 100) / 100;
51
- const usedGb = Math.round((usedBytes / 1024 ** 3) * 100) / 100;
52
- const percent = Math.round((usedBytes / totalBytes) * 100);
53
- return { percent, usedGb, totalGb };
79
+ async function readAvailableMemory() {
80
+ if (process.platform === 'darwin') {
81
+ try {
82
+ const { stdout } = await execFileAsync('vm_stat', [], { timeout: 2000 });
83
+ const available = parseVmStat(stdout);
84
+ if (available !== null)
85
+ return Math.min(available, node_os_1.default.totalmem());
86
+ }
87
+ catch {
88
+ // Fall through to Node's figure.
89
+ }
90
+ }
91
+ return node_os_1.default.freemem();
92
+ }
93
+ /** Memory usage from the total and the available byte count. */
94
+ function collectMemory(availableBytes = node_os_1.default.freemem(), totalBytes = node_os_1.default.totalmem()) {
95
+ const usedBytes = Math.max(0, totalBytes - availableBytes);
96
+ return {
97
+ percent: percentOf(usedBytes, totalBytes),
98
+ usedGb: gb(usedBytes),
99
+ totalGb: gb(totalBytes),
100
+ };
54
101
  }
55
102
  /**
56
- * Collect disk usage via fs.statfs (Node 18.15+).
103
+ * Disk usage via fs.statfs, computed like `df`: used / (used + available to
104
+ * unprivileged users), so blocks reserved for root don't hide a full disk.
57
105
  */
58
106
  async function collectDisk(path) {
59
107
  const stats = await node_fs_1.promises.statfs(path);
60
108
  const totalBytes = stats.blocks * stats.bsize;
61
- const freeBytes = stats.bavail * stats.bsize;
62
- const usedBytes = totalBytes - freeBytes;
63
- const totalGb = Math.round((totalBytes / 1024 ** 3) * 100) / 100;
64
- const usedGb = Math.round((usedBytes / 1024 ** 3) * 100) / 100;
65
- const percent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
66
- return { percent, usedGb, totalGb };
109
+ const usedBytes = (stats.blocks - stats.bfree) * stats.bsize;
110
+ const availBytes = stats.bavail * stats.bsize;
111
+ return {
112
+ percent: percentOf(usedBytes, usedBytes + availBytes),
113
+ usedGb: gb(usedBytes),
114
+ totalGb: gb(totalBytes),
115
+ };
67
116
  }
68
117
  //# sourceMappingURL=collectors.js.map