@nocville/npc 0.2.1 → 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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -120
  3. package/dist/cjs/cli.js +55 -34
  4. package/dist/cjs/core/base-npc.js +8 -0
  5. package/dist/cjs/core/config.js +19 -2
  6. package/dist/cjs/core/errors.js +23 -0
  7. package/dist/cjs/core/http.js +121 -0
  8. package/dist/cjs/core/pixel-art.js +110 -0
  9. package/dist/cjs/core/polling-npc.js +99 -14
  10. package/dist/cjs/core/session-filter.js +3 -1
  11. package/dist/cjs/core/text.js +13 -0
  12. package/dist/cjs/core/thresholds.js +29 -0
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/npcs/agent-common/session.js +1 -0
  15. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  16. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  17. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  18. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  19. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  20. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  21. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  22. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  23. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  24. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +45 -19
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  30. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  31. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  32. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  33. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  34. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  35. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  36. package/dist/cli.js +56 -35
  37. package/dist/core/base-npc.d.ts +5 -0
  38. package/dist/core/base-npc.js +8 -0
  39. package/dist/core/config.d.ts +5 -0
  40. package/dist/core/config.js +18 -2
  41. package/dist/core/errors.d.ts +11 -0
  42. package/dist/core/errors.js +18 -0
  43. package/dist/core/http.d.ts +52 -0
  44. package/dist/core/http.js +111 -0
  45. package/dist/core/pixel-art.d.ts +38 -0
  46. package/dist/core/pixel-art.js +105 -0
  47. package/dist/core/polling-npc.d.ts +22 -1
  48. package/dist/core/polling-npc.js +98 -13
  49. package/dist/core/session-filter.d.ts +2 -2
  50. package/dist/core/session-filter.js +3 -1
  51. package/dist/core/text.d.ts +6 -0
  52. package/dist/core/text.js +12 -0
  53. package/dist/core/thresholds.d.ts +9 -1
  54. package/dist/core/thresholds.js +27 -0
  55. package/dist/index.d.ts +5 -2
  56. package/dist/index.js +4 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  59. package/dist/npcs/github-watcher/defaults.js +7 -1
  60. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  61. package/dist/npcs/github-watcher/github-api.js +80 -45
  62. package/dist/npcs/github-watcher/index.d.ts +6 -0
  63. package/dist/npcs/github-watcher/index.js +83 -37
  64. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  65. package/dist/npcs/internet-connectivity/checks.js +70 -67
  66. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  67. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  68. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  69. package/dist/npcs/internet-connectivity/index.js +135 -133
  70. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  71. package/dist/npcs/log-watcher/buffer.js +58 -0
  72. package/dist/npcs/log-watcher/index.d.ts +22 -21
  73. package/dist/npcs/log-watcher/index.js +112 -106
  74. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  75. package/dist/npcs/log-watcher/tail.js +101 -54
  76. package/dist/npcs/process-watcher/index.d.ts +1 -1
  77. package/dist/npcs/process-watcher/index.js +62 -52
  78. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  79. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  80. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  81. package/dist/npcs/receptionist/defaults.js +4 -0
  82. package/dist/npcs/receptionist/index.d.ts +12 -2
  83. package/dist/npcs/receptionist/index.js +46 -20
  84. package/dist/npcs/receptionist/weather.d.ts +8 -6
  85. package/dist/npcs/receptionist/weather.js +39 -45
  86. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  87. package/dist/npcs/system-monitor/collectors.js +64 -17
  88. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  89. package/dist/npcs/system-monitor/index.d.ts +2 -1
  90. package/dist/npcs/system-monitor/index.js +54 -30
  91. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  92. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  93. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  94. package/dist/npcs/web-page-watcher/capture.js +21 -1
  95. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  96. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  97. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  98. package/dist/npcs/web-page-watcher/index.js +121 -70
  99. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  100. package/package.json +2 -7
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { parseArgs, resolveConfig, stringFlag, numberFlag, boolFlag } from './core/config.js';
5
+ import { parseArgs, resolveConfig, stringFlag, numberFlag, boolFlag, listFlag, } from './core/config.js';
6
+ import { NpcConfigError } from './core/errors.js';
6
7
  import { Logger } from './core/logger.js';
7
8
  import { ensureSessionFilterTemplate, loadSessionFilter, sessionPublishDecision, } from './core/session-filter.js';
8
9
  // NPC modules are imported lazily per command: `claude-code hook` / `codex hook`
@@ -77,17 +78,23 @@ Internet Connectivity Options:
77
78
  --poll-interval=<ms> Poll interval in ms (default: 30000)
78
79
  --dns-hosts=<list> Comma-separated DNS hosts (default: google.com,cloudflare.com)
79
80
  --http-urls=<list> Comma-separated HTTP URLs to check
80
- --latency-hosts=<list> Comma-separated hosts for latency checks
81
+ --latency-hosts=<list> Comma-separated TCP latency targets: host, host:port or
82
+ [ipv6]:port (default: 8.8.8.8:443,1.1.1.1:443;
83
+ port 80 when omitted)
81
84
  --slow-threshold=<ms> Slow response threshold in ms (default: 1000)
85
+ --timeout=<ms> Per-check timeout in ms (default: 5000)
82
86
 
83
87
  Process Watcher Options:
84
88
  --poll-interval=<ms> Poll interval in ms (default: 15000)
85
- --processes=<list> Comma-separated process names to watch
89
+ --processes=<list> Comma-separated executable names to watch (python
90
+ also matches python3.11; a /path matches exactly)
86
91
  --cpu-warn=<n> CPU warning threshold % (default: 80)
87
92
  --mem-warn=<n> Memory warning threshold MB (default: 500)
88
93
 
89
94
  Log Watcher Options:
90
- --files=<list> Comma-separated log file paths
95
+ --files=<list> Comma-separated log file paths (followed across
96
+ rotation; files may appear later). Lines shown on
97
+ the screen have secrets redacted.
91
98
  --error-pattern=<regex> Error pattern (default: ERROR|FATAL)
92
99
  --warning-pattern=<regex> Warning pattern (default: WARN)
93
100
  --window=<seconds> Sliding window in seconds (default: 300)
@@ -98,23 +105,30 @@ Log Watcher Options:
98
105
  Receptionist Options:
99
106
  --poll-interval=<ms> Clock tick in ms (default: 60000)
100
107
  --lat=<n> --lon=<n> Explicit coordinates for weather
101
- --location=<name> Place name to geocode (else IP geo)
108
+ --location=<name> Place name to geocode (open-meteo)
109
+ --no-ip-geo Without --lat/--lon or --location, don't look up
110
+ this machine's IP location (ipapi.co); show time only
102
111
  --fahrenheit Show temperature in °F (default: °C)
103
112
 
104
113
  GitHub Watcher Options:
105
114
  --repo=<owner/name> Repository to watch (required)
106
115
  --branch=<name> Branch to watch (default: repo default branch)
107
- --token=<token> GitHub token (or GITHUB_TOKEN env)
108
- --poll-interval=<ms> Poll interval in ms (default: 60000)
116
+ --token=<token> GitHub token (prefer the GITHUB_TOKEN env: flags are
117
+ visible in the process list)
118
+ --poll-interval=<ms> Poll interval in ms (default: 60000 with a token,
119
+ 90000 without, to stay under GitHub's 60/hour)
109
120
 
110
121
  Web Page Watcher Options:
111
- --url=<url> Page to watch (required)
122
+ --url=<url> http(s) page to watch (required)
112
123
  --mode=<auto|screenshot|basic> Render mode (default: auto)
113
124
  --interval=<ms> Poll interval in ms (default: 60000, floor 15000)
114
- --viewport=<WxH> Browser viewport (default: screen x 8)
115
- --screen=<WxH> TV pixel resolution (default: 160x120; e.g. 160x100)
116
- --wait-until=<event> Puppeteer waitUntil (default: networkidle2)
125
+ --viewport=<WxH> Browser viewport (default: screen x 8, max 4096x4096)
126
+ --screen=<WxH> TV pixel resolution (default: 160x120, max 320x240)
127
+ --wait-until=<event> load, domcontentloaded, networkidle0 or networkidle2
128
+ (default: networkidle2)
117
129
  --chrome-path=<path> Chrome executable (or CHROME_PATH env)
130
+ --no-chrome-sandbox Run Chrome without its sandbox (only for trusted
131
+ pages; automatic when running as root)
118
132
  (needs puppeteer-core + Chrome for the pixel-art TV; falls back to basic)
119
133
  `.trim();
120
134
  async function main() {
@@ -275,18 +289,10 @@ async function main() {
275
289
  if (!config.serviceId) {
276
290
  config.serviceId = 'internet-connectivity';
277
291
  }
278
- const dnsHosts = stringFlag(parsed.flags, 'dns-hosts')?.split(',');
279
- const httpUrls = stringFlag(parsed.flags, 'http-urls')?.split(',');
280
- const latencyHostsRaw = stringFlag(parsed.flags, 'latency-hosts')?.split(',');
281
- const latencyHosts = latencyHostsRaw?.map((h) => ({ host: h.trim(), port: 80 }));
282
292
  const { InternetConnectivityNPC } = await import('./npcs/internet-connectivity/index.js');
283
293
  const npc = new InternetConnectivityNPC({
284
294
  ...config,
285
- pollInterval: numberFlag(parsed.flags, 'poll-interval'),
286
- dnsHosts,
287
- httpUrls,
288
- latencyHosts,
289
- slowThreshold: numberFlag(parsed.flags, 'slow-threshold'),
295
+ ...InternetConnectivityNPC.configFromFlags(parsed.flags),
290
296
  });
291
297
  logger.info('Starting internet-connectivity NPC');
292
298
  await npc.start();
@@ -296,8 +302,8 @@ async function main() {
296
302
  if (!config.serviceId) {
297
303
  config.serviceId = 'process-watcher';
298
304
  }
299
- const processesStr = stringFlag(parsed.flags, 'processes');
300
- if (!processesStr) {
305
+ const processes = listFlag(parsed.flags, 'processes');
306
+ if (!processes) {
301
307
  console.error('Error: --processes is required (comma-separated list)');
302
308
  process.exit(1);
303
309
  }
@@ -305,7 +311,7 @@ async function main() {
305
311
  const npc = new ProcessWatcherNPC({
306
312
  ...config,
307
313
  pollInterval: numberFlag(parsed.flags, 'poll-interval'),
308
- processes: processesStr.split(','),
314
+ processes,
309
315
  cpuWarn: numberFlag(parsed.flags, 'cpu-warn'),
310
316
  memWarn: numberFlag(parsed.flags, 'mem-warn'),
311
317
  });
@@ -314,8 +320,8 @@ async function main() {
314
320
  break;
315
321
  }
316
322
  case 'log-watcher': {
317
- const filesStr = stringFlag(parsed.flags, 'files');
318
- if (!filesStr) {
323
+ const files = listFlag(parsed.flags, 'files');
324
+ if (!files) {
319
325
  console.error('Error: --files is required (comma-separated list)');
320
326
  process.exit(1);
321
327
  }
@@ -325,7 +331,7 @@ async function main() {
325
331
  const { LogWatcherNPC } = await import('./npcs/log-watcher/index.js');
326
332
  const npc = new LogWatcherNPC({
327
333
  ...config,
328
- files: filesStr.split(','),
334
+ files,
329
335
  errorPattern: stringFlag(parsed.flags, 'error-pattern'),
330
336
  warningPattern: stringFlag(parsed.flags, 'warning-pattern'),
331
337
  windowSeconds: numberFlag(parsed.flags, 'window'),
@@ -349,6 +355,7 @@ async function main() {
349
355
  lon: numberFlag(parsed.flags, 'lon'),
350
356
  location: stringFlag(parsed.flags, 'location'),
351
357
  fahrenheit: boolFlag(parsed.flags, 'fahrenheit') ?? false,
358
+ ipGeo: boolFlag(parsed.flags, 'no-ip-geo') !== true,
352
359
  });
353
360
  logger.info('Starting receptionist NPC');
354
361
  await npc.start();
@@ -365,11 +372,15 @@ async function main() {
365
372
  config.serviceId = `github-${shortName.replace(/[^a-zA-Z0-9_-]/g, '-')}`.slice(0, 100);
366
373
  }
367
374
  const { GitHubWatcherNPC } = await import('./npcs/github-watcher/index.js');
375
+ const tokenFlag = stringFlag(parsed.flags, 'token');
376
+ if (tokenFlag) {
377
+ logger.warn('--token is visible to other local users in the process list; prefer GITHUB_TOKEN');
378
+ }
368
379
  const npc = new GitHubWatcherNPC({
369
380
  ...config,
370
381
  repo,
371
382
  branch: stringFlag(parsed.flags, 'branch'),
372
- token: stringFlag(parsed.flags, 'token') ?? process.env['GITHUB_TOKEN'],
383
+ token: tokenFlag ?? process.env['GITHUB_TOKEN'],
373
384
  pollInterval: numberFlag(parsed.flags, 'poll-interval'),
374
385
  });
375
386
  logger.info('Starting github-watcher NPC');
@@ -385,22 +396,28 @@ async function main() {
385
396
  const { hostnameOf } = await import('./npcs/web-page-watcher/basic-mode.js');
386
397
  const { parseDimensions } = await import('./npcs/web-page-watcher/defaults.js');
387
398
  const { WebPageWatcherNPC } = await import('./npcs/web-page-watcher/index.js');
388
- const parseWxH = (v) => v ? (parseDimensions(v) ?? undefined) : undefined;
399
+ const parseWxH = (flag) => {
400
+ const v = stringFlag(parsed.flags, flag);
401
+ if (v === undefined)
402
+ return undefined;
403
+ const dims = parseDimensions(v);
404
+ if (!dims)
405
+ throw new NpcConfigError(`--${flag} must look like 160x120`);
406
+ return dims;
407
+ };
389
408
  if (!config.serviceId) {
390
409
  config.serviceId = `webpage-${hostnameOf(url)}`
391
410
  .replace(/[^a-zA-Z0-9_.-]/g, '-')
392
411
  .slice(0, 100);
393
412
  }
394
- const modeFlag = stringFlag(parsed.flags, 'mode');
395
- const mode = modeFlag === 'auto' || modeFlag === 'screenshot' || modeFlag === 'basic'
396
- ? modeFlag
397
- : undefined;
398
- const viewport = parseWxH(stringFlag(parsed.flags, 'viewport'));
399
- const screen = parseWxH(stringFlag(parsed.flags, 'screen'));
413
+ const viewport = parseWxH('viewport');
414
+ const screen = parseWxH('screen');
400
415
  const npc = new WebPageWatcherNPC({
401
416
  ...config,
402
417
  url,
403
- mode,
418
+ // Validated (with a clear error) by the constructor.
419
+ mode: stringFlag(parsed.flags, 'mode'),
420
+ chromeSandbox: boolFlag(parsed.flags, 'no-chrome-sandbox') !== true,
404
421
  chromePath: stringFlag(parsed.flags, 'chrome-path'),
405
422
  waitUntil: stringFlag(parsed.flags, 'wait-until'),
406
423
  viewportWidth: viewport?.width,
@@ -543,6 +560,10 @@ main().catch((err) => {
543
560
  process.stdout.write('{}\n');
544
561
  return;
545
562
  }
563
+ if (err instanceof NpcConfigError) {
564
+ console.error(`Error: ${err.message}`);
565
+ process.exit(1);
566
+ }
546
567
  console.error('Fatal error:', err);
547
568
  process.exit(1);
548
569
  });
@@ -20,6 +20,11 @@ export declare abstract class BaseNPC {
20
20
  protected get defaultDisplayName(): string | null;
21
21
  /** The banner name to send, clamped to the API's 32-char limit, or undefined. */
22
22
  private pushName;
23
+ /**
24
+ * NPC type sent as the push `kind` (groups NPCs for per-type rules such as "all process
25
+ * watchers work in the Server Room"). Defaults to `name` when it is a valid slug.
26
+ */
27
+ get kind(): string | undefined;
23
28
  /** NPC archetype — steers movement speed and default behavior. Override per NPC. */
24
29
  get archetype(): Archetype;
25
30
  /** Base behavior hints sent on every push (merged with per-cycle overrides). */
@@ -39,6 +39,13 @@ export class BaseNPC {
39
39
  const name = this.displayName;
40
40
  return name ? truncate(name, 32) : undefined;
41
41
  }
42
+ /**
43
+ * NPC type sent as the push `kind` (groups NPCs for per-type rules such as "all process
44
+ * watchers work in the Server Room"). Defaults to `name` when it is a valid slug.
45
+ */
46
+ get kind() {
47
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(this.name) ? this.name : undefined;
48
+ }
42
49
  /** NPC archetype — steers movement speed and default behavior. Override per NPC. */
43
50
  get archetype() {
44
51
  return 'service';
@@ -113,6 +120,7 @@ export class BaseNPC {
113
120
  message: metrics.message ? truncate(metrics.message, 500) : undefined,
114
121
  metrics: metrics.metrics,
115
122
  archetype: this.archetype,
123
+ kind: this.kind,
116
124
  subStatus: metrics.subStatus,
117
125
  screenData: metrics.screenData,
118
126
  behavior: Object.keys(behavior).length > 0 ? behavior : undefined,
@@ -18,5 +18,10 @@ export declare function parseArgs(argv: string[]): ParsedArgs;
18
18
  export declare function resolveConfig(flags: Record<string, string | boolean>, overrides?: Partial<NpcConfig>): NpcConfig;
19
19
  export declare function stringFlag(flags: Record<string, string | boolean>, key: string): string | undefined;
20
20
  export declare function boolFlag(flags: Record<string, string | boolean>, key: string): boolean | undefined;
21
+ /**
22
+ * A comma-separated list flag (`--processes=nginx,redis`): entries trimmed,
23
+ * empties dropped, duplicates removed. Undefined when the flag is absent or empty.
24
+ */
25
+ export declare function listFlag(flags: Record<string, string | boolean>, key: string): string[] | undefined;
21
26
  export declare function numberFlag(flags: Record<string, string | boolean>, key: string): number | undefined;
22
27
  //# sourceMappingURL=config.d.ts.map
@@ -89,11 +89,27 @@ export function boolFlag(flags, key) {
89
89
  return false;
90
90
  return undefined;
91
91
  }
92
+ /**
93
+ * A comma-separated list flag (`--processes=nginx,redis`): entries trimmed,
94
+ * empties dropped, duplicates removed. Undefined when the flag is absent or empty.
95
+ */
96
+ export function listFlag(flags, key) {
97
+ const raw = stringFlag(flags, key);
98
+ if (raw === undefined)
99
+ return undefined;
100
+ const items = [
101
+ ...new Set(raw
102
+ .split(',')
103
+ .map((s) => s.trim())
104
+ .filter(Boolean)),
105
+ ];
106
+ return items.length > 0 ? items : undefined;
107
+ }
92
108
  export function numberFlag(flags, key) {
93
109
  const val = flags[key];
94
- if (typeof val === 'string') {
110
+ if (typeof val === 'string' && val.trim() !== '') {
95
111
  const n = Number(val);
96
- if (!Number.isNaN(n))
112
+ if (Number.isFinite(n))
97
113
  return n;
98
114
  }
99
115
  return undefined;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A user-facing configuration problem (bad flag value, unsupported platform).
3
+ * The CLI prints only its message and exits 1 — no stack trace — so throw it
4
+ * for anything the user can fix by changing how they run the NPC.
5
+ */
6
+ export declare class NpcConfigError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Throw an NpcConfigError on Windows, for NPCs that rely on POSIX tools or paths. */
10
+ export declare function assertPosix(npcName: string, hint: string): void;
11
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A user-facing configuration problem (bad flag value, unsupported platform).
3
+ * The CLI prints only its message and exits 1 — no stack trace — so throw it
4
+ * for anything the user can fix by changing how they run the NPC.
5
+ */
6
+ export class NpcConfigError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'NpcConfigError';
10
+ }
11
+ }
12
+ /** Throw an NpcConfigError on Windows, for NPCs that rely on POSIX tools or paths. */
13
+ export function assertPosix(npcName, hint) {
14
+ if (process.platform === 'win32') {
15
+ throw new NpcConfigError(`${npcName} is not supported on Windows. ${hint}`);
16
+ }
17
+ }
18
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Small, bounded HTTP helpers for NPCs that read untrusted remote content
3
+ * (weather/geo APIs, GitHub, watched web pages). Every call has a timeout and
4
+ * every body read has a byte cap, so a slow or hostile server can neither hang
5
+ * a collection nor balloon memory.
6
+ */
7
+ /** The `User-Agent` sent by every NPC request. */
8
+ export declare const NPC_USER_AGENT = "nocville-npc";
9
+ /** Default cap for a response body read into memory. */
10
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
11
+ /** True for an absolute http(s) URL (anything else — file:, data:, javascript: — is refused). */
12
+ export declare function isHttpUrl(value: string): boolean;
13
+ /** A URL rendered for logs/screens: scheme, host and path only — never credentials or query. */
14
+ export declare function displayUrl(value: string): string;
15
+ /** The minimal response surface the helpers read (global fetch satisfies it). */
16
+ export interface ReadableResponse {
17
+ headers?: {
18
+ get(name: string): string | null;
19
+ };
20
+ body?: ReadableStream<Uint8Array> | null;
21
+ text(): Promise<string>;
22
+ }
23
+ /**
24
+ * Read a response body as UTF-8, keeping at most `maxBytes`. Streams when the
25
+ * body is available (cancelling the rest); otherwise falls back to `text()`
26
+ * after rejecting an oversized declared Content-Length. Returns the (possibly
27
+ * truncated) text and whether it was truncated.
28
+ */
29
+ export declare function readTextCapped(res: ReadableResponse, maxBytes?: number): Promise<{
30
+ text: string;
31
+ truncated: boolean;
32
+ }>;
33
+ /** Discard a response body without reading it (frees the connection). */
34
+ export declare function discardBody(res: {
35
+ body?: ReadableStream<Uint8Array> | null;
36
+ }): Promise<void>;
37
+ export interface FetchJsonOptions {
38
+ timeoutMs: number;
39
+ headers?: Record<string, string>;
40
+ maxBytes?: number;
41
+ }
42
+ /**
43
+ * GET a JSON document with a timeout and size cap. Returns null on any network
44
+ * error, non-2xx status, oversized body or malformed JSON — callers degrade
45
+ * gracefully rather than throw.
46
+ */
47
+ export declare function fetchJson(url: string, opts: FetchJsonOptions): Promise<unknown>;
48
+ /** Narrow an unknown JSON value to a plain object (arrays and null excluded). */
49
+ export declare function asRecord(value: unknown): Record<string, unknown> | null;
50
+ /** A finite number from an unknown JSON value, else null. */
51
+ export declare function finiteNumber(value: unknown): number | null;
52
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Small, bounded HTTP helpers for NPCs that read untrusted remote content
3
+ * (weather/geo APIs, GitHub, watched web pages). Every call has a timeout and
4
+ * every body read has a byte cap, so a slow or hostile server can neither hang
5
+ * a collection nor balloon memory.
6
+ */
7
+ /** The `User-Agent` sent by every NPC request. */
8
+ export const NPC_USER_AGENT = 'nocville-npc';
9
+ /** Default cap for a response body read into memory. */
10
+ export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
11
+ /** True for an absolute http(s) URL (anything else — file:, data:, javascript: — is refused). */
12
+ export function isHttpUrl(value) {
13
+ try {
14
+ const url = new URL(value);
15
+ return url.protocol === 'http:' || url.protocol === 'https:';
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /** A URL rendered for logs/screens: scheme, host and path only — never credentials or query. */
22
+ export function displayUrl(value) {
23
+ try {
24
+ const url = new URL(value);
25
+ return `${url.protocol}//${url.host}${url.pathname === '/' ? '' : url.pathname}`;
26
+ }
27
+ catch {
28
+ return '(invalid url)';
29
+ }
30
+ }
31
+ /**
32
+ * Read a response body as UTF-8, keeping at most `maxBytes`. Streams when the
33
+ * body is available (cancelling the rest); otherwise falls back to `text()`
34
+ * after rejecting an oversized declared Content-Length. Returns the (possibly
35
+ * truncated) text and whether it was truncated.
36
+ */
37
+ export async function readTextCapped(res, maxBytes = DEFAULT_MAX_BODY_BYTES) {
38
+ const reader = res.body?.getReader();
39
+ if (!reader) {
40
+ const declared = Number(res.headers?.get('content-length') ?? NaN);
41
+ if (Number.isFinite(declared) && declared > maxBytes)
42
+ return { text: '', truncated: true };
43
+ const text = await res.text();
44
+ return text.length > maxBytes
45
+ ? { text: text.slice(0, maxBytes), truncated: true }
46
+ : { text, truncated: false };
47
+ }
48
+ const chunks = [];
49
+ let total = 0;
50
+ let truncated = false;
51
+ try {
52
+ for (;;) {
53
+ const { done, value } = await reader.read();
54
+ if (done)
55
+ break;
56
+ if (total + value.byteLength > maxBytes) {
57
+ chunks.push(value.subarray(0, maxBytes - total));
58
+ total = maxBytes;
59
+ truncated = true;
60
+ break;
61
+ }
62
+ chunks.push(value);
63
+ total += value.byteLength;
64
+ }
65
+ }
66
+ finally {
67
+ if (truncated)
68
+ await reader.cancel().catch(() => undefined);
69
+ reader.releaseLock();
70
+ }
71
+ return { text: new TextDecoder().decode(Buffer.concat(chunks)), truncated };
72
+ }
73
+ /** Discard a response body without reading it (frees the connection). */
74
+ export async function discardBody(res) {
75
+ await res.body?.cancel().catch(() => undefined);
76
+ }
77
+ /**
78
+ * GET a JSON document with a timeout and size cap. Returns null on any network
79
+ * error, non-2xx status, oversized body or malformed JSON — callers degrade
80
+ * gracefully rather than throw.
81
+ */
82
+ export async function fetchJson(url, opts) {
83
+ try {
84
+ const res = await fetch(url, {
85
+ signal: AbortSignal.timeout(opts.timeoutMs),
86
+ headers: { 'User-Agent': NPC_USER_AGENT, Accept: 'application/json', ...opts.headers },
87
+ });
88
+ if (!res.ok) {
89
+ await discardBody(res);
90
+ return null;
91
+ }
92
+ const { text, truncated } = await readTextCapped(res, opts.maxBytes ?? 256 * 1024);
93
+ if (truncated)
94
+ return null;
95
+ return JSON.parse(text);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Narrow an unknown JSON value to a plain object (arrays and null excluded). */
102
+ export function asRecord(value) {
103
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
104
+ ? value
105
+ : null;
106
+ }
107
+ /** A finite number from an unknown JSON value, else null. */
108
+ export function finiteNumber(value) {
109
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
110
+ }
111
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,38 @@
1
+ import type { ScreenData } from '@nocville/node';
2
+ export interface PixelArtFrames {
3
+ width: number;
4
+ height: number;
5
+ /** RGBA bytes per frame (width × height × 4). */
6
+ frames: Array<Uint8Array | Uint8ClampedArray>;
7
+ /** Animation speed when there is more than one frame (1–12, default 4). */
8
+ fps?: number;
9
+ }
10
+ /** Encode RGBA frames as `pixel-image` screen data. Throws with a fix-it message. */
11
+ export declare function encodePixelArt(art: PixelArtFrames): ScreenData;
12
+ /**
13
+ * Load a PNG (path or bytes) as pixel-device screen data. For animation, stack frames
14
+ * top to bottom in one PNG and pass `frameHeight`.
15
+ */
16
+ export declare function pixelArtFromPng(png: string | Buffer, opts?: {
17
+ frameHeight?: number;
18
+ fps?: number;
19
+ }): ScreenData;
20
+ /**
21
+ * Companion declaration for a pixel device: `size` in tiles (art is 32px per tile).
22
+ * Freestanding by default so it sits on the floor next to the NPC's home.
23
+ */
24
+ export declare function pixelDevice(opts: {
25
+ w: number;
26
+ h: number;
27
+ label?: string;
28
+ placement?: 'freestanding' | 'adjacent-wall' | 'on-desk';
29
+ }): {
30
+ screenType: 'pixel-device';
31
+ size: {
32
+ w: number;
33
+ h: number;
34
+ };
35
+ placement: 'freestanding' | 'adjacent-wall' | 'on-desk';
36
+ label?: string;
37
+ };
38
+ //# sourceMappingURL=pixel-art.d.ts.map
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { PNG } from 'pngjs';
3
+ /**
4
+ * Helpers for custom pixel-art companions (`screenType: 'pixel-device'`): your NPC's own
5
+ * device, sign or machine, drawn frameless beside it in the office.
6
+ *
7
+ * Art is 32 pixels per tile (a 2×3-tile device is 64×96, like the office furniture) and is
8
+ * sent as `pixel-image` screen data: a palette of up to 255 colours plus one byte per pixel.
9
+ * Fully transparent pixels (alpha < 128) become see-through, and several frames stacked top
10
+ * to bottom animate at `fps`. Push a new image whenever its state changes (e.g. an LED turns
11
+ * red); the server rate-limits pushes, so animate with frames rather than many pushes.
12
+ */
13
+ /** Largest single screen-data value the API accepts (base64 pixels). */
14
+ const MAX_VALUE = 220_000;
15
+ const MAX_COLORS = 255;
16
+ const MAX_FRAMES = 16;
17
+ /** Encode RGBA frames as `pixel-image` screen data. Throws with a fix-it message. */
18
+ export function encodePixelArt(art) {
19
+ const { width, height, frames } = art;
20
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
21
+ throw new Error('Pixel art needs a positive whole-number width and height.');
22
+ if (width > 192 || height > 192)
23
+ throw new Error(`Pixel art is ${width}×${height}; keep it within 192×192 (6×6 tiles).`);
24
+ if (frames.length < 1 || frames.length > MAX_FRAMES)
25
+ throw new Error(`Pixel art needs 1–${MAX_FRAMES} frames (got ${frames.length}).`);
26
+ const size = width * height;
27
+ for (const frame of frames)
28
+ if (frame.length !== size * 4)
29
+ throw new Error(`Each frame must be ${width}×${height} RGBA (${size * 4} bytes).`);
30
+ // Index 0 is reserved for transparency when any pixel is see-through.
31
+ const transparent = frames.some((f) => {
32
+ for (let i = 3; i < f.length; i += 4)
33
+ if (f[i] < 128)
34
+ return true;
35
+ return false;
36
+ });
37
+ const colors = new Map();
38
+ const palette = transparent ? [0, 0, 0] : [];
39
+ const pixels = Buffer.alloc(size * frames.length);
40
+ frames.forEach((frame, f) => {
41
+ for (let i = 0; i < size; i++) {
42
+ const r = frame[i * 4], g = frame[i * 4 + 1], b = frame[i * 4 + 2];
43
+ if (transparent && frame[i * 4 + 3] < 128) {
44
+ pixels[f * size + i] = 0;
45
+ continue;
46
+ }
47
+ const key = (r << 16) | (g << 8) | b;
48
+ let index = colors.get(key);
49
+ if (index === undefined) {
50
+ index = palette.length / 3;
51
+ if (index > MAX_COLORS)
52
+ throw new Error(`Pixel art uses more than ${MAX_COLORS} colours; reduce the palette (pixel art usually needs 8–32).`);
53
+ colors.set(key, index);
54
+ palette.push(r, g, b);
55
+ }
56
+ pixels[f * size + i] = index;
57
+ }
58
+ });
59
+ const tokens = [`idx8`, `${width}x${height}`, `p${palette.length / 3}`];
60
+ if (transparent)
61
+ tokens.push('t0');
62
+ if (frames.length > 1) {
63
+ const fps = Math.min(12, Math.max(1, Math.round(art.fps ?? 4)));
64
+ tokens.push(`f${frames.length}`, `fps${fps}`);
65
+ }
66
+ const encoded = pixels.toString('base64');
67
+ if (encoded.length > MAX_VALUE)
68
+ throw new Error('Pixel art is too large to send; use fewer frames or a smaller size.');
69
+ return {
70
+ layout: 'pixel-image',
71
+ values: [
72
+ { label: 'fmt', value: tokens.join(';') },
73
+ { label: 'palette', value: Buffer.from(palette).toString('base64') },
74
+ { label: 'pixels', value: encoded },
75
+ ],
76
+ };
77
+ }
78
+ /**
79
+ * Load a PNG (path or bytes) as pixel-device screen data. For animation, stack frames
80
+ * top to bottom in one PNG and pass `frameHeight`.
81
+ */
82
+ export function pixelArtFromPng(png, opts = {}) {
83
+ const image = PNG.sync.read(typeof png === 'string' ? readFileSync(png) : png);
84
+ const frameHeight = opts.frameHeight ?? image.height;
85
+ if (frameHeight < 1 || image.height % frameHeight !== 0)
86
+ throw new Error(`PNG height ${image.height} is not a whole number of ${frameHeight}px frames.`);
87
+ const frameBytes = image.width * frameHeight * 4;
88
+ const frames = [];
89
+ for (let offset = 0; offset < image.data.length; offset += frameBytes)
90
+ frames.push(image.data.subarray(offset, offset + frameBytes));
91
+ return encodePixelArt({ width: image.width, height: frameHeight, frames, fps: opts.fps });
92
+ }
93
+ /**
94
+ * Companion declaration for a pixel device: `size` in tiles (art is 32px per tile).
95
+ * Freestanding by default so it sits on the floor next to the NPC's home.
96
+ */
97
+ export function pixelDevice(opts) {
98
+ return {
99
+ screenType: 'pixel-device',
100
+ size: { w: opts.w, h: opts.h },
101
+ placement: opts.placement ?? 'freestanding',
102
+ ...(opts.label ? { label: opts.label } : {}),
103
+ };
104
+ }
105
+ //# sourceMappingURL=pixel-art.js.map
@@ -3,9 +3,21 @@ import type { NpcConfig, CollectedMetrics } from './types.js';
3
3
  export interface PollingNpcConfig extends NpcConfig {
4
4
  pollInterval?: number;
5
5
  }
6
+ /**
7
+ * Lower bound for any poll interval, so a typo can't exhaust the API's
8
+ * per-service rate limit (60 pushes/min) or hammer the watched source.
9
+ */
10
+ export declare const MIN_POLL_INTERVAL_MS = 5000;
6
11
  export declare abstract class PollingNPC extends BaseNPC {
7
12
  private timer;
8
- private collecting;
13
+ private keepaliveTimer;
14
+ /** The running collection + push, if any (awaited by `onStop`). */
15
+ private inflight;
16
+ /** A `collectNow()` arrived while a collection was in flight: run once more after it. */
17
+ private rerunRequested;
18
+ /** The last successfully pushed cycle, replayed (without its screen) by the keepalive. */
19
+ private lastPushed;
20
+ private lastPushAt;
9
21
  protected pollInterval: number;
10
22
  constructor(config: PollingNpcConfig);
11
23
  /** Scale staleness to the poll cadence: the server marks unknown at ~2x the interval. */
@@ -13,6 +25,15 @@ export declare abstract class PollingNPC extends BaseNPC {
13
25
  protected abstract collect(): Promise<CollectedMetrics>;
14
26
  protected onStart(): Promise<void>;
15
27
  protected onStop(): Promise<void>;
28
+ /**
29
+ * Collect and push now instead of waiting for the next tick — for NPCs that
30
+ * also react to events (e.g. a fresh error line). Coalesces: if a collection
31
+ * is already running, exactly one more runs after it.
32
+ */
33
+ protected collectNow(): void;
16
34
  private runCollection;
35
+ private collectAndPush;
36
+ /** Replay the last cycle (minus its screen, which the server keeps) if nothing was pushed recently. */
37
+ private keepalive;
17
38
  }
18
39
  //# sourceMappingURL=polling-npc.d.ts.map