@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.
- package/README.md +55 -17
- package/dist/cjs/cli.js +94 -39
- package/dist/cjs/core/base-npc.js +15 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +21 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/agent-common/shim.js +8 -2
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/office-characters/index.js +92 -0
- package/dist/cjs/npcs/process-watcher/index.js +60 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +6 -93
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/security/index.js +26 -0
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +95 -40
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +15 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/agent-common/shim.js +8 -2
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/office-characters/index.d.ts +50 -0
- package/dist/npcs/office-characters/index.js +82 -0
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +61 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
- package/dist/npcs/process-watcher/process-scanner.js +97 -70
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +3 -12
- package/dist/npcs/receptionist/index.js +6 -93
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/security/index.d.ts +12 -0
- package/dist/npcs/security/index.js +22 -0
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -2
package/dist/core/config.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
package/dist/core/polling-npc.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { BaseNPC } from './base-npc.js';
|
|
2
|
+
/**
|
|
3
|
+
* Lower bound for any poll interval, so a typo can't exhaust the API's
|
|
4
|
+
* per-service rate limit (60 pushes/min) or hammer the watched source.
|
|
5
|
+
*/
|
|
6
|
+
export const MIN_POLL_INTERVAL_MS = 5000;
|
|
7
|
+
/** How long `stop()` waits for an in-flight collection before pushing offline anyway. */
|
|
8
|
+
const STOP_DRAIN_TIMEOUT_MS = 10_000;
|
|
2
9
|
function clamp(n, min, max) {
|
|
3
10
|
return Math.min(max, Math.max(min, n));
|
|
4
11
|
}
|
|
5
12
|
export class PollingNPC extends BaseNPC {
|
|
6
13
|
timer = null;
|
|
7
|
-
|
|
14
|
+
keepaliveTimer = null;
|
|
15
|
+
/** The running collection + push, if any (awaited by `onStop`). */
|
|
16
|
+
inflight = null;
|
|
17
|
+
/** A `collectNow()` arrived while a collection was in flight: run once more after it. */
|
|
18
|
+
rerunRequested = false;
|
|
19
|
+
/** The last successfully pushed cycle, replayed (without its screen) by the keepalive. */
|
|
20
|
+
lastPushed = null;
|
|
21
|
+
lastPushAt = 0;
|
|
8
22
|
pollInterval;
|
|
9
23
|
constructor(config) {
|
|
10
24
|
super(config);
|
|
11
|
-
|
|
25
|
+
const requested = config.pollInterval ?? 15000;
|
|
26
|
+
this.pollInterval = Number.isFinite(requested)
|
|
27
|
+
? Math.max(MIN_POLL_INTERVAL_MS, requested)
|
|
28
|
+
: 15000;
|
|
12
29
|
}
|
|
13
30
|
/** Scale staleness to the poll cadence: the server marks unknown at ~2x the interval. */
|
|
14
31
|
get heartbeatSeconds() {
|
|
@@ -17,14 +34,22 @@ export class PollingNPC extends BaseNPC {
|
|
|
17
34
|
async onStart() {
|
|
18
35
|
// Collect immediately on start
|
|
19
36
|
await this.runCollection();
|
|
37
|
+
// Stopped while the first collection ran: don't arm timers nobody will clear.
|
|
38
|
+
if (this.state !== 'running')
|
|
39
|
+
return;
|
|
20
40
|
// Then on interval
|
|
21
41
|
this.timer = setInterval(() => {
|
|
22
42
|
void this.runCollection();
|
|
23
43
|
}, this.pollInterval);
|
|
24
|
-
//
|
|
25
|
-
// collections
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
// Only very slow pollers (> heartbeatSeconds, i.e. > 30 min given the clamp)
|
|
45
|
+
// could go stale between collections. Keep them alive by replaying the
|
|
46
|
+
// last cycle. The SDK's own heartbeat is not used: it omits `map`, `name`
|
|
47
|
+
// and `alertConfig`, so it would spawn a twin on the team's default map.
|
|
48
|
+
const heartbeatMs = this.heartbeatSeconds * 1000;
|
|
49
|
+
if (this.pollInterval > heartbeatMs) {
|
|
50
|
+
this.keepaliveTimer = setInterval(() => {
|
|
51
|
+
void this.keepalive(heartbeatMs);
|
|
52
|
+
}, heartbeatMs);
|
|
28
53
|
}
|
|
29
54
|
}
|
|
30
55
|
async onStop() {
|
|
@@ -32,29 +57,89 @@ export class PollingNPC extends BaseNPC {
|
|
|
32
57
|
clearInterval(this.timer);
|
|
33
58
|
this.timer = null;
|
|
34
59
|
}
|
|
35
|
-
this.
|
|
60
|
+
if (this.keepaliveTimer) {
|
|
61
|
+
clearInterval(this.keepaliveTimer);
|
|
62
|
+
this.keepaliveTimer = null;
|
|
63
|
+
}
|
|
64
|
+
this.rerunRequested = false;
|
|
65
|
+
// Let an in-flight push land before the offline push, so it can't arrive
|
|
66
|
+
// afterwards and respawn the NPC. Bounded: a hung collector never blocks exit.
|
|
67
|
+
if (this.inflight) {
|
|
68
|
+
let timeout;
|
|
69
|
+
await Promise.race([
|
|
70
|
+
this.inflight,
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
timeout = setTimeout(resolve, STOP_DRAIN_TIMEOUT_MS);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
36
77
|
}
|
|
37
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Collect and push now instead of waiting for the next tick — for NPCs that
|
|
80
|
+
* also react to events (e.g. a fresh error line). Coalesces: if a collection
|
|
81
|
+
* is already running, exactly one more runs after it.
|
|
82
|
+
*/
|
|
83
|
+
collectNow() {
|
|
38
84
|
if (this.state !== 'running')
|
|
39
85
|
return;
|
|
86
|
+
if (this.inflight) {
|
|
87
|
+
this.rerunRequested = true;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
void this.runCollection();
|
|
91
|
+
}
|
|
92
|
+
runCollection() {
|
|
93
|
+
if (this.state !== 'running')
|
|
94
|
+
return Promise.resolve();
|
|
40
95
|
// Overlap guard: if a slow collector is still running, skip this tick rather
|
|
41
96
|
// than stacking collections (load-bearing for screenshot/render collectors).
|
|
42
|
-
if (this.
|
|
97
|
+
if (this.inflight) {
|
|
43
98
|
this.logger.debug('Previous collection still in flight, skipping this tick');
|
|
44
|
-
return;
|
|
99
|
+
return this.inflight;
|
|
45
100
|
}
|
|
46
|
-
this.
|
|
101
|
+
const run = this.collectAndPush().finally(() => {
|
|
102
|
+
this.inflight = null;
|
|
103
|
+
if (this.rerunRequested && this.state === 'running') {
|
|
104
|
+
this.rerunRequested = false;
|
|
105
|
+
void this.runCollection();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
this.inflight = run;
|
|
109
|
+
return run;
|
|
110
|
+
}
|
|
111
|
+
async collectAndPush() {
|
|
47
112
|
try {
|
|
48
113
|
const metrics = await this.collect();
|
|
114
|
+
// Stopped mid-collection: never push after (or race) the offline push.
|
|
115
|
+
if (this.state !== 'running')
|
|
116
|
+
return;
|
|
49
117
|
await this.pushMetrics(metrics);
|
|
118
|
+
this.lastPushed = metrics;
|
|
119
|
+
this.lastPushAt = Date.now();
|
|
50
120
|
}
|
|
51
121
|
catch (err) {
|
|
122
|
+
if (this.state !== 'running')
|
|
123
|
+
return; // teardown aborted the collector
|
|
52
124
|
this.logger.error('Collection failed', {
|
|
53
125
|
error: err instanceof Error ? err.message : String(err),
|
|
54
126
|
});
|
|
55
127
|
}
|
|
56
|
-
|
|
57
|
-
|
|
128
|
+
}
|
|
129
|
+
/** Replay the last cycle (minus its screen, which the server keeps) if nothing was pushed recently. */
|
|
130
|
+
async keepalive(heartbeatMs) {
|
|
131
|
+
if (this.state !== 'running' || this.inflight || !this.lastPushed)
|
|
132
|
+
return;
|
|
133
|
+
if (Date.now() - this.lastPushAt < heartbeatMs / 2)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
await this.pushMetrics({ ...this.lastPushed, screenData: undefined });
|
|
137
|
+
this.lastPushAt = Date.now();
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.logger.debug('Keepalive push failed', {
|
|
141
|
+
error: err instanceof Error ? err.message : String(err),
|
|
142
|
+
});
|
|
58
143
|
}
|
|
59
144
|
}
|
|
60
145
|
}
|
package/dist/core/text.d.ts
CHANGED
|
@@ -3,4 +3,10 @@
|
|
|
3
3
|
* Used to keep messages and screen lines within API limits.
|
|
4
4
|
*/
|
|
5
5
|
export declare function truncate(s: string, max?: number): string;
|
|
6
|
+
/**
|
|
7
|
+
* Make untrusted remote text (commit messages, page titles, log lines) safe to
|
|
8
|
+
* display: drop control and bidi-override characters, collapse whitespace
|
|
9
|
+
* (including newlines and tabs) to single spaces, trim, and truncate.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sanitizeText(s: string, max?: number): string;
|
|
6
12
|
//# sourceMappingURL=text.d.ts.map
|
package/dist/core/text.js
CHANGED
|
@@ -9,4 +9,16 @@ export function truncate(s, max = 200) {
|
|
|
9
9
|
return s.slice(0, Math.max(0, max));
|
|
10
10
|
return s.slice(0, max - 1) + '…';
|
|
11
11
|
}
|
|
12
|
+
// C0/C1 controls (incl. ESC, so no terminal escapes) plus the bidi overrides,
|
|
13
|
+
// isolates and marks that can visually reorder text on a banner.
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const UNSAFE_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F--]/g;
|
|
16
|
+
/**
|
|
17
|
+
* Make untrusted remote text (commit messages, page titles, log lines) safe to
|
|
18
|
+
* display: drop control and bidi-override characters, collapse whitespace
|
|
19
|
+
* (including newlines and tabs) to single spaces, trim, and truncate.
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeText(s, max = 200) {
|
|
22
|
+
return truncate(s.replace(UNSAFE_CHARS, '').replace(/\s+/g, ' ').trim(), max);
|
|
23
|
+
}
|
|
12
24
|
//# sourceMappingURL=text.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Status, ThresholdConfig } from './types.js';
|
|
1
|
+
import type { Mood, Status, ThresholdConfig } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Evaluate a numeric value against thresholds to determine status.
|
|
4
4
|
*/
|
|
@@ -7,4 +7,12 @@ export declare function evaluateThreshold(value: number, config: ThresholdConfig
|
|
|
7
7
|
* Returns the worst (most severe) status from an array.
|
|
8
8
|
*/
|
|
9
9
|
export declare function worstStatus(statuses: Status[]): Status;
|
|
10
|
+
/** The NPC mood that matches a status (drives the NPC's demeanour and emotes). */
|
|
11
|
+
export declare function moodForStatus(status: Status): Mood;
|
|
12
|
+
/**
|
|
13
|
+
* Validate a warning/critical pair from user flags: both finite, and critical
|
|
14
|
+
* at least as severe as warning for the threshold's direction. Returns an error
|
|
15
|
+
* message, or null when the pair is usable.
|
|
16
|
+
*/
|
|
17
|
+
export declare function thresholdProblem(label: string, t: ThresholdConfig): string | null;
|
|
10
18
|
//# sourceMappingURL=thresholds.d.ts.map
|
package/dist/core/thresholds.js
CHANGED
|
@@ -41,4 +41,31 @@ export function worstStatus(statuses) {
|
|
|
41
41
|
}
|
|
42
42
|
return worst;
|
|
43
43
|
}
|
|
44
|
+
/** The NPC mood that matches a status (drives the NPC's demeanour and emotes). */
|
|
45
|
+
export function moodForStatus(status) {
|
|
46
|
+
switch (status) {
|
|
47
|
+
case 'healthy':
|
|
48
|
+
return 'happy';
|
|
49
|
+
case 'warning':
|
|
50
|
+
return 'concerned';
|
|
51
|
+
case 'critical':
|
|
52
|
+
return 'stressed';
|
|
53
|
+
default:
|
|
54
|
+
return 'idle';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate a warning/critical pair from user flags: both finite, and critical
|
|
59
|
+
* at least as severe as warning for the threshold's direction. Returns an error
|
|
60
|
+
* message, or null when the pair is usable.
|
|
61
|
+
*/
|
|
62
|
+
export function thresholdProblem(label, t) {
|
|
63
|
+
if (!Number.isFinite(t.warning) || !Number.isFinite(t.critical)) {
|
|
64
|
+
return `${label}: thresholds must be numbers`;
|
|
65
|
+
}
|
|
66
|
+
const inverted = (t.direction ?? 'above') === 'above' ? t.critical < t.warning : t.critical > t.warning;
|
|
67
|
+
return inverted
|
|
68
|
+
? `${label}: critical (${t.critical}) must be at least as severe as warning (${t.warning})`
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
44
71
|
//# sourceMappingURL=thresholds.js.map
|
package/dist/core/types.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface NpcConfig {
|
|
|
14
14
|
map?: string;
|
|
15
15
|
/** Friendly display name for the NPC banner. Overrides the per-NPC default. */
|
|
16
16
|
name?: string;
|
|
17
|
+
/** Optional local observer for publishing health; never included in status payloads. */
|
|
18
|
+
onPushResult?: (result: {
|
|
19
|
+
success: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
}) => void;
|
|
17
22
|
}
|
|
18
23
|
export interface CollectedMetrics {
|
|
19
24
|
status: Status;
|