@nocville/npc 0.2.3 → 0.3.1
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 +36 -15
- package/dist/cjs/cli.js +44 -8
- package/dist/cjs/core/base-npc.js +7 -0
- package/dist/cjs/core/polling-npc.js +8 -0
- package/dist/cjs/index.js +13 -1
- package/dist/cjs/npcs/agent-common/shim.js +8 -2
- package/dist/cjs/npcs/github-watcher/github.js +109 -0
- package/dist/cjs/npcs/office-characters/index.js +102 -0
- package/dist/cjs/npcs/process-watcher/index.js +0 -1
- package/dist/cjs/npcs/process-watcher/process-scanner.js +42 -4
- package/dist/cjs/npcs/receptionist/index.js +6 -119
- package/dist/cjs/npcs/security/index.js +26 -0
- package/dist/cjs/npcs/web-page-watcher/basic.js +58 -0
- package/dist/cjs/npcs/web-page-watcher/capture.js +26 -0
- package/dist/cjs/npcs/web-page-watcher/chrome.js +62 -0
- package/dist/cli.js +44 -8
- package/dist/core/base-npc.js +7 -0
- package/dist/core/polling-npc.d.ts +3 -0
- package/dist/core/polling-npc.js +8 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/npcs/agent-common/shim.js +8 -2
- package/dist/npcs/github-watcher/github.d.ts +39 -0
- package/dist/npcs/github-watcher/github.js +103 -0
- package/dist/npcs/office-characters/index.d.ts +55 -0
- package/dist/npcs/office-characters/index.js +91 -0
- package/dist/npcs/process-watcher/index.js +1 -2
- package/dist/npcs/process-watcher/process-scanner.d.ts +3 -1
- package/dist/npcs/process-watcher/process-scanner.js +41 -4
- package/dist/npcs/receptionist/index.d.ts +2 -21
- package/dist/npcs/receptionist/index.js +6 -119
- package/dist/npcs/security/index.d.ts +12 -0
- package/dist/npcs/security/index.js +22 -0
- package/dist/npcs/web-page-watcher/basic.d.ts +16 -0
- package/dist/npcs/web-page-watcher/basic.js +52 -0
- package/dist/npcs/web-page-watcher/capture.js +26 -0
- package/dist/npcs/web-page-watcher/chrome.d.ts +46 -0
- package/dist/npcs/web-page-watcher/chrome.js +57 -0
- package/package.json +2 -2
|
@@ -2,36 +2,9 @@
|
|
|
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");
|
|
6
|
-
const defaults_js_1 = require("./defaults.js");
|
|
7
|
-
const weather_js_1 = require("./weather.js");
|
|
8
5
|
class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
|
|
9
|
-
explicitCoords;
|
|
10
|
-
location;
|
|
11
|
-
fahrenheit;
|
|
12
|
-
ipGeo;
|
|
13
|
-
coords = null;
|
|
14
|
-
/** Earliest time to retry geocoding / IP geo after a failure. */
|
|
15
|
-
nextGeoAttempt = 0;
|
|
16
|
-
weather = null;
|
|
17
|
-
weatherFetchedAt = 0;
|
|
18
|
-
/** Earliest time to retry the weather after a failure. */
|
|
19
|
-
nextWeatherAttempt = 0;
|
|
20
6
|
constructor(config) {
|
|
21
|
-
super({ ...config, pollInterval: config.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
|
-
}
|
|
28
|
-
this.explicitCoords =
|
|
29
|
-
config.lat !== undefined && config.lon !== undefined
|
|
30
|
-
? { lat: config.lat, lon: config.lon }
|
|
31
|
-
: null;
|
|
32
|
-
this.location = config.location?.trim() || undefined;
|
|
33
|
-
this.fahrenheit = config.fahrenheit ?? false;
|
|
34
|
-
this.ipGeo = config.ipGeo ?? true;
|
|
7
|
+
super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
|
|
35
8
|
}
|
|
36
9
|
get name() {
|
|
37
10
|
return 'receptionist';
|
|
@@ -44,102 +17,16 @@ class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
|
|
|
44
17
|
}
|
|
45
18
|
get behavior() {
|
|
46
19
|
return {
|
|
20
|
+
role: 'receptionist',
|
|
21
|
+
activity: 'idle',
|
|
47
22
|
greetPlayers: true,
|
|
48
|
-
patrol: { everyMinutes: [15, 30], stops: 5 },
|
|
49
23
|
idle: 'stay',
|
|
50
|
-
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
get companion() {
|
|
54
|
-
return {
|
|
55
|
-
screenType: 'welcome-sign',
|
|
56
|
-
size: { w: 2, h: 1 },
|
|
57
|
-
placement: 'adjacent-wall',
|
|
58
|
-
label: 'Welcome',
|
|
24
|
+
idlePose: 'sit',
|
|
59
25
|
};
|
|
60
26
|
}
|
|
61
27
|
async collect() {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const { day, date, time } = (0, weather_js_1.formatClock)(now);
|
|
65
|
-
const values = [
|
|
66
|
-
{ label: 'time', value: time },
|
|
67
|
-
{ label: 'day', value: day },
|
|
68
|
-
{ label: 'date', value: date },
|
|
69
|
-
];
|
|
70
|
-
let message = `${day} ${time}`;
|
|
71
|
-
const metrics = { weather_ok: this.weather ? 1 : 0 };
|
|
72
|
-
if (this.weather) {
|
|
73
|
-
const temp = this.fahrenheit
|
|
74
|
-
? `${(0, weather_js_1.toFahrenheit)(this.weather.tempC)}°F`
|
|
75
|
-
: `${this.weather.tempC}°C`;
|
|
76
|
-
message += ` • ${temp} ${this.weather.text}`;
|
|
77
|
-
values.push({ label: 'temp', value: temp }, { label: 'condition', value: this.weather.text }, { label: 'icon', value: this.weather.icon });
|
|
78
|
-
metrics['temp_c'] = this.weather.tempC;
|
|
79
|
-
metrics['weather_code'] = this.weather.code;
|
|
80
|
-
metrics['wind_kph'] = this.weather.windKph;
|
|
81
|
-
}
|
|
82
|
-
// The receptionist is a greeter/clock — it stays healthy even when weather
|
|
83
|
-
// is unavailable (degraded time-only mode).
|
|
84
|
-
return {
|
|
85
|
-
status: 'healthy',
|
|
86
|
-
mood: 'happy',
|
|
87
|
-
message,
|
|
88
|
-
screenData: { layout: 'welcome-sign', values },
|
|
89
|
-
metrics,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
async refreshWeather() {
|
|
93
|
-
const now = Date.now();
|
|
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)
|
|
101
|
-
return;
|
|
102
|
-
if (this.weather && now - this.weatherFetchedAt < defaults_js_1.DEFAULTS.weatherRefreshMs)
|
|
103
|
-
return;
|
|
104
|
-
const w = await (0, weather_js_1.fetchWeather)(this.coords, defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
105
|
-
if (w) {
|
|
106
|
-
this.weather = w;
|
|
107
|
-
this.weatherFetchedAt = now;
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
this.nextWeatherAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
async resolveCoords(now) {
|
|
114
|
-
if (this.explicitCoords) {
|
|
115
|
-
this.coords = this.explicitCoords;
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
// Back off after a failure rather than hitting the keyless endpoints every tick.
|
|
119
|
-
if (now < this.nextGeoAttempt)
|
|
120
|
-
return;
|
|
121
|
-
this.nextGeoAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
|
|
122
|
-
if (this.location) {
|
|
123
|
-
this.coords = await (0, weather_js_1.geocodeLocation)(this.location, defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
124
|
-
if (this.coords)
|
|
125
|
-
return;
|
|
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;
|
|
134
|
-
}
|
|
135
|
-
this.coords = await (0, weather_js_1.autoGeo)(defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
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');
|
|
142
|
-
}
|
|
28
|
+
// The server handles conversation and movement. Heartbeats stay quiet.
|
|
29
|
+
return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
|
|
143
30
|
}
|
|
144
31
|
}
|
|
145
32
|
exports.ReceptionistNPC = ReceptionistNPC;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SecurityNPC = void 0;
|
|
4
|
+
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
|
+
class SecurityNPC extends polling_npc_js_1.PollingNPC {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
|
|
8
|
+
}
|
|
9
|
+
get name() {
|
|
10
|
+
return 'security';
|
|
11
|
+
}
|
|
12
|
+
get defaultDisplayName() {
|
|
13
|
+
return 'Security';
|
|
14
|
+
}
|
|
15
|
+
get archetype() {
|
|
16
|
+
return 'custom';
|
|
17
|
+
}
|
|
18
|
+
get behavior() {
|
|
19
|
+
return { role: 'security', activity: 'idle', idlePose: 'stand' };
|
|
20
|
+
}
|
|
21
|
+
async collect() {
|
|
22
|
+
return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.SecurityNPC = SecurityNPC;
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractTitle = extractTitle;
|
|
4
|
+
exports.statusFromHttp = statusFromHttp;
|
|
5
|
+
exports.basicCheck = basicCheck;
|
|
6
|
+
exports.hostnameOf = hostnameOf;
|
|
7
|
+
/** Extract the <title> text from an HTML string (empty if none). */
|
|
8
|
+
function extractTitle(html) {
|
|
9
|
+
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
10
|
+
return m ? m[1].replace(/\s+/g, ' ').trim().slice(0, 80) : '';
|
|
11
|
+
}
|
|
12
|
+
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
13
|
+
function statusFromHttp(code, timedOut = false) {
|
|
14
|
+
if (code >= 200 && code < 400)
|
|
15
|
+
return 'healthy';
|
|
16
|
+
if (code >= 400 && code < 500)
|
|
17
|
+
return 'warning';
|
|
18
|
+
if (code >= 500)
|
|
19
|
+
return 'critical';
|
|
20
|
+
return timedOut ? 'warning' : 'critical'; // 0 = network error
|
|
21
|
+
}
|
|
22
|
+
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
23
|
+
async function basicCheck(url, timeoutMs = 8000) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(url, {
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
headers: { 'User-Agent': 'nocville-npc' },
|
|
30
|
+
redirect: 'follow',
|
|
31
|
+
});
|
|
32
|
+
let title = '';
|
|
33
|
+
try {
|
|
34
|
+
title = extractTitle(await res.text());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// body unavailable; leave title blank
|
|
38
|
+
}
|
|
39
|
+
return { status: res.status, title, ok: res.ok, timedOut: false };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const timedOut = err instanceof Error && err.name === 'AbortError';
|
|
43
|
+
return { status: 0, title: '', ok: false, timedOut };
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Best-effort hostname for a URL, falling back to the raw string. */
|
|
50
|
+
function hostnameOf(url) {
|
|
51
|
+
try {
|
|
52
|
+
return new URL(url).hostname;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return url.slice(0, 40);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=basic.js.map
|
|
@@ -5,6 +5,7 @@ exports.chromeArgs = chromeArgs;
|
|
|
5
5
|
exports.runningAsRoot = runningAsRoot;
|
|
6
6
|
exports.loadPuppeteer = loadPuppeteer;
|
|
7
7
|
exports.browserConnected = browserConnected;
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
8
9
|
const node_fs_1 = require("node:fs");
|
|
9
10
|
const node_child_process_1 = require("node:child_process");
|
|
10
11
|
const MAC_PATHS = [
|
|
@@ -50,6 +51,25 @@ function findChrome(explicit) {
|
|
|
50
51
|
return p;
|
|
51
52
|
return null;
|
|
52
53
|
}
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
const roots = [
|
|
56
|
+
process.env['LOCALAPPDATA'],
|
|
57
|
+
process.env['PROGRAMFILES'],
|
|
58
|
+
process.env['PROGRAMFILES(X86)'],
|
|
59
|
+
].filter((p) => !!p);
|
|
60
|
+
for (const root of roots)
|
|
61
|
+
for (const relative of [
|
|
62
|
+
['Google', 'Chrome', 'Application', 'chrome.exe'],
|
|
63
|
+
['Microsoft', 'Edge', 'Application', 'msedge.exe'],
|
|
64
|
+
['Chromium', 'Application', 'chrome.exe'],
|
|
65
|
+
['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
|
|
66
|
+
]) {
|
|
67
|
+
const path = (0, node_path_1.join)(root, ...relative);
|
|
68
|
+
if ((0, node_fs_1.existsSync)(path))
|
|
69
|
+
return path;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
53
73
|
if (process.platform === 'linux') {
|
|
54
74
|
for (const bin of LINUX_BINARIES) {
|
|
55
75
|
const resolved = which(bin);
|
|
@@ -83,6 +103,12 @@ function runningAsRoot() {
|
|
|
83
103
|
*/
|
|
84
104
|
async function loadPuppeteer() {
|
|
85
105
|
try {
|
|
106
|
+
// Prefer CommonJS in bundled runtimes so Electron resolves through its
|
|
107
|
+
// ASAR loader. Native ESM installations use the optional dynamic import below.
|
|
108
|
+
if (typeof require === 'function') {
|
|
109
|
+
const mod = require('puppeteer-core');
|
|
110
|
+
return mod.default ?? mod;
|
|
111
|
+
}
|
|
86
112
|
const dynImport = new Function('m', 'return import(m)');
|
|
87
113
|
const mod = await dynImport('puppeteer-core');
|
|
88
114
|
return (mod.default ?? mod);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findChrome = findChrome;
|
|
4
|
+
exports.loadPuppeteer = loadPuppeteer;
|
|
5
|
+
exports.browserConnected = browserConnected;
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const CHROME_PATHS = {
|
|
8
|
+
darwin: [
|
|
9
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
10
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
11
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
12
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
13
|
+
],
|
|
14
|
+
linux: [
|
|
15
|
+
'/usr/bin/google-chrome',
|
|
16
|
+
'/usr/bin/google-chrome-stable',
|
|
17
|
+
'/usr/bin/chromium',
|
|
18
|
+
'/usr/bin/chromium-browser',
|
|
19
|
+
'/usr/bin/microsoft-edge',
|
|
20
|
+
'/snap/bin/chromium',
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
/** Find a usable Chrome/Chromium executable: explicit path → env → known paths. */
|
|
24
|
+
function findChrome(explicit) {
|
|
25
|
+
const envPath = process.env['CHROME_PATH'];
|
|
26
|
+
const candidates = [
|
|
27
|
+
...(explicit ? [explicit] : []),
|
|
28
|
+
...(envPath ? [envPath] : []),
|
|
29
|
+
...(CHROME_PATHS[process.platform] ?? []),
|
|
30
|
+
];
|
|
31
|
+
for (const p of candidates) {
|
|
32
|
+
if (p && (0, node_fs_1.existsSync)(p))
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
39
|
+
* so the package builds and runs when it isn't installed. Returns null if the
|
|
40
|
+
* module is absent.
|
|
41
|
+
*/
|
|
42
|
+
async function loadPuppeteer() {
|
|
43
|
+
try {
|
|
44
|
+
// Indirect dynamic import so TypeScript/bundlers don't require the module
|
|
45
|
+
// at build time.
|
|
46
|
+
const dynImport = new Function('m', 'return import(m)');
|
|
47
|
+
const mod = await dynImport('puppeteer-core');
|
|
48
|
+
return (mod.default ?? mod);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** True if the browser reports a live connection. */
|
|
55
|
+
function browserConnected(browser) {
|
|
56
|
+
if (typeof browser.connected === 'boolean')
|
|
57
|
+
return browser.connected;
|
|
58
|
+
if (typeof browser.isConnected === 'function')
|
|
59
|
+
return browser.isConnected();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=chrome.js.map
|
package/dist/cli.js
CHANGED
|
@@ -47,7 +47,16 @@ Monitoring NPC types:
|
|
|
47
47
|
internet-connectivity Monitor network connectivity
|
|
48
48
|
process-watcher Watch for running processes
|
|
49
49
|
log-watcher Watch log files for errors
|
|
50
|
-
receptionist
|
|
50
|
+
receptionist Lobby greeter with office check-ins and jokes
|
|
51
|
+
security Indoor/outdoor patrols and exterior door watch
|
|
52
|
+
janitor Mops suspicious spots and hunts office bugs
|
|
53
|
+
barista Takes increasingly absurd coffee orders
|
|
54
|
+
office-cat Naps on desks and supervises keyboards
|
|
55
|
+
office-dog Naps, barks at coworkers and runs around
|
|
56
|
+
plant-caretaker Waters plants and reviews their performance
|
|
57
|
+
it-technician Inspects tech gear and recommends reboots
|
|
58
|
+
out-of-touch-executive Interrupts coworkers between long lounge breaks
|
|
59
|
+
slightly-inappropriate-hr Desk-based HR with questionable people skills
|
|
51
60
|
github-watcher Call out new commits on a repo
|
|
52
61
|
web-page-watcher Render a watched web page as a pixel-art TV
|
|
53
62
|
|
|
@@ -102,13 +111,8 @@ Log Watcher Options:
|
|
|
102
111
|
--error-crit=<n> Error count for critical status (default: 20)
|
|
103
112
|
--report-interval=<ms> Report interval in ms (default: 15000)
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
--poll-interval=<ms>
|
|
107
|
-
--lat=<n> --lon=<n> Explicit coordinates for weather
|
|
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
|
|
111
|
-
--fahrenheit Show temperature in °F (default: °C)
|
|
114
|
+
Office Character Options:
|
|
115
|
+
--poll-interval=<ms> Heartbeat interval in ms (default: 60000)
|
|
112
116
|
|
|
113
117
|
GitHub Watcher Options:
|
|
114
118
|
--repo=<owner/name> Repository to watch (required)
|
|
@@ -361,6 +365,38 @@ async function main() {
|
|
|
361
365
|
await npc.start();
|
|
362
366
|
break;
|
|
363
367
|
}
|
|
368
|
+
case 'security': {
|
|
369
|
+
if (!config.serviceId)
|
|
370
|
+
config.serviceId = 'security';
|
|
371
|
+
const { SecurityNPC } = await import('./npcs/security/index.js');
|
|
372
|
+
const npc = new SecurityNPC({
|
|
373
|
+
...config,
|
|
374
|
+
pollInterval: numberFlag(parsed.flags, 'poll-interval'),
|
|
375
|
+
});
|
|
376
|
+
logger.info('Starting security NPC');
|
|
377
|
+
await npc.start();
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'janitor':
|
|
381
|
+
case 'barista':
|
|
382
|
+
case 'office-cat':
|
|
383
|
+
case 'office-dog':
|
|
384
|
+
case 'plant-caretaker':
|
|
385
|
+
case 'it-technician':
|
|
386
|
+
case 'out-of-touch-executive':
|
|
387
|
+
case 'slightly-inappropriate-hr': {
|
|
388
|
+
if (!config.serviceId)
|
|
389
|
+
config.serviceId = parsed.command;
|
|
390
|
+
const { OFFICE_CHARACTER_NPCS } = await import('./npcs/office-characters/index.js');
|
|
391
|
+
const NPCClass = OFFICE_CHARACTER_NPCS[parsed.command];
|
|
392
|
+
const npc = new NPCClass({
|
|
393
|
+
...config,
|
|
394
|
+
pollInterval: numberFlag(parsed.flags, 'poll-interval'),
|
|
395
|
+
});
|
|
396
|
+
logger.info(`Starting ${parsed.command} NPC`);
|
|
397
|
+
await npc.start();
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
364
400
|
case 'github-watcher': {
|
|
365
401
|
const repo = stringFlag(parsed.flags, 'repo');
|
|
366
402
|
if (!repo) {
|
package/dist/core/base-npc.js
CHANGED
|
@@ -128,6 +128,13 @@ export class BaseNPC {
|
|
|
128
128
|
alertConfig: { heartbeatInterval: this.heartbeatSeconds },
|
|
129
129
|
map: this.config.map,
|
|
130
130
|
});
|
|
131
|
+
// An observer cannot interrupt monitoring or retries.
|
|
132
|
+
try {
|
|
133
|
+
this.config.onPushResult?.({ success: result.success, error: result.error });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* observer only */
|
|
137
|
+
}
|
|
131
138
|
if (!result.success) {
|
|
132
139
|
this.reportPushFailure(result.error);
|
|
133
140
|
}
|
|
@@ -2,6 +2,8 @@ import { BaseNPC } from './base-npc.js';
|
|
|
2
2
|
import type { NpcConfig, CollectedMetrics } from './types.js';
|
|
3
3
|
export interface PollingNpcConfig extends NpcConfig {
|
|
4
4
|
pollInterval?: number;
|
|
5
|
+
/** Local diagnostics; never included in published payloads. */
|
|
6
|
+
onCollection?: (metrics: CollectedMetrics) => void;
|
|
5
7
|
}
|
|
6
8
|
/**
|
|
7
9
|
* Lower bound for any poll interval, so a typo can't exhaust the API's
|
|
@@ -19,6 +21,7 @@ export declare abstract class PollingNPC extends BaseNPC {
|
|
|
19
21
|
private lastPushed;
|
|
20
22
|
private lastPushAt;
|
|
21
23
|
protected pollInterval: number;
|
|
24
|
+
private onCollection?;
|
|
22
25
|
constructor(config: PollingNpcConfig);
|
|
23
26
|
/** Scale staleness to the poll cadence: the server marks unknown at ~2x the interval. */
|
|
24
27
|
get heartbeatSeconds(): number;
|
package/dist/core/polling-npc.js
CHANGED
|
@@ -20,8 +20,10 @@ export class PollingNPC extends BaseNPC {
|
|
|
20
20
|
lastPushed = null;
|
|
21
21
|
lastPushAt = 0;
|
|
22
22
|
pollInterval;
|
|
23
|
+
onCollection;
|
|
23
24
|
constructor(config) {
|
|
24
25
|
super(config);
|
|
26
|
+
this.onCollection = config.onCollection;
|
|
25
27
|
const requested = config.pollInterval ?? 15000;
|
|
26
28
|
this.pollInterval = Number.isFinite(requested)
|
|
27
29
|
? Math.max(MIN_POLL_INTERVAL_MS, requested)
|
|
@@ -114,6 +116,12 @@ export class PollingNPC extends BaseNPC {
|
|
|
114
116
|
// Stopped mid-collection: never push after (or race) the offline push.
|
|
115
117
|
if (this.state !== 'running')
|
|
116
118
|
return;
|
|
119
|
+
try {
|
|
120
|
+
this.onCollection?.(metrics);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* Diagnostics cannot stop publishing. */
|
|
124
|
+
}
|
|
117
125
|
await this.pushMetrics(metrics);
|
|
118
126
|
this.lastPushed = metrics;
|
|
119
127
|
this.lastPushAt = Date.now();
|
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;
|
package/dist/index.d.ts
CHANGED
|
@@ -41,4 +41,6 @@ export type { GeminiSetupOptions } from './npcs/gemini/index.js';
|
|
|
41
41
|
export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
|
|
42
42
|
export type { CursorSetupOptions } from './npcs/cursor/index.js';
|
|
43
43
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
44
|
+
export { SecurityNPC } from './npcs/security/index.js';
|
|
45
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
44
46
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -25,4 +25,6 @@ export { WebPageWatcherNPC } from './npcs/web-page-watcher/index.js';
|
|
|
25
25
|
export { GEMINI_DEFAULTS, GEMINI_ADAPTER, geminiTransition, geminiServiceId, handleGeminiHookEvent, setupGeminiHooks, uninstallGeminiHooks, installGeminiHooksInto, GEMINI_HOOK_EVENTS, } from './npcs/gemini/index.js';
|
|
26
26
|
export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
|
|
27
27
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
28
|
+
export { SecurityNPC } from './npcs/security/index.js';
|
|
29
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
28
30
|
//# sourceMappingURL=index.js.map
|
|
@@ -77,13 +77,19 @@ try {
|
|
|
77
77
|
if (neutral) process.stdout.write('{}\\n');
|
|
78
78
|
`;
|
|
79
79
|
export function installHookShim(nodePath, cliPath) {
|
|
80
|
+
// Desktop ships this same CLI with Electron's Node runtime. Keep the flag in
|
|
81
|
+
// the launcher (never globally), inherited by flush/watchdog child processes.
|
|
82
|
+
const electron = !!process.versions['electron'];
|
|
80
83
|
writeChanged(RUNTIME_POINTER(), JSON.stringify({ node: nodePath, cli: cliPath }, null, 2) + '\n', 0o600);
|
|
81
84
|
writeChanged(join(dirname(RUNTIME_POINTER()), 'node-path'), nodePath + '\n', 0o600);
|
|
82
85
|
writeChanged(join(dirname(SHIM_PATH()), 'launch.cjs'), LAUNCHER, 0o600);
|
|
83
|
-
writeChanged(SHIM_PATH(), SHIM, 0o755);
|
|
86
|
+
writeChanged(SHIM_PATH(), electron ? SHIM.replace('exec "$NODE"', 'export ELECTRON_RUN_AS_NODE=1\nexec "$NODE"') : SHIM, 0o755);
|
|
84
87
|
chmodSync(SHIM_PATH(), 0o755);
|
|
85
88
|
// Both launchers use the same bootstrap, including imported-Cursor detection.
|
|
86
|
-
|
|
89
|
+
const windowsShim = electron
|
|
90
|
+
? WINDOWS_SHIM.replace(':launch\n', ':launch\nset "ELECTRON_RUN_AS_NODE=1"\n')
|
|
91
|
+
: WINDOWS_SHIM;
|
|
92
|
+
writeChanged(SHIM_PATH() + '.cmd', windowsShim.replace(/\n/g, '\r\n'), 0o755);
|
|
87
93
|
pruneOldRuntimes(dirname(RUNTIME_POINTER()));
|
|
88
94
|
return SHIM_PATH();
|
|
89
95
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CommitInfo {
|
|
2
|
+
sha: string;
|
|
3
|
+
author: string;
|
|
4
|
+
message: string;
|
|
5
|
+
date: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FetchCommitsResult {
|
|
8
|
+
/** 304 Not Modified — the ETag matched, nothing changed, no rate-limit cost. */
|
|
9
|
+
notModified: boolean;
|
|
10
|
+
commits: CommitInfo[];
|
|
11
|
+
etag: string | null;
|
|
12
|
+
rateRemaining: number | null;
|
|
13
|
+
status: number;
|
|
14
|
+
}
|
|
15
|
+
export interface FetchOptions {
|
|
16
|
+
token?: string;
|
|
17
|
+
etag?: string | null;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
perPage?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
23
|
+
* Passing the previous ETag yields a 304 (which does not count against the
|
|
24
|
+
* unauthenticated 60/hour limit) when nothing has changed. Never throws —
|
|
25
|
+
* network errors surface as `status: 0`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fetchCommits(repo: string, opts?: FetchOptions): Promise<FetchCommitsResult>;
|
|
28
|
+
/** Count how many commits landed within the last `hours`. */
|
|
29
|
+
export declare function commitsWithin(commits: CommitInfo[], hours: number, now?: number): number;
|
|
30
|
+
/** Short relative-time string like "3m ago", "2h ago", "1d ago". */
|
|
31
|
+
export declare function relativeTime(iso: string, now?: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* Return the commits newer than `lastSeenSha`, newest-first. When the sha isn't
|
|
34
|
+
* present in the window (more than a page of new commits, or rewritten history)
|
|
35
|
+
* every fetched commit is treated as new. An undefined sha means "not seeded
|
|
36
|
+
* yet" — returns empty so the caller can seed without a spurious announcement.
|
|
37
|
+
*/
|
|
38
|
+
export declare function newCommitsSince(commits: CommitInfo[], lastSeenSha: string | null): CommitInfo[];
|
|
39
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function toNum(v) {
|
|
2
|
+
if (v === null)
|
|
3
|
+
return null;
|
|
4
|
+
const n = Number(v);
|
|
5
|
+
return Number.isNaN(n) ? null : n;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
9
|
+
* Passing the previous ETag yields a 304 (which does not count against the
|
|
10
|
+
* unauthenticated 60/hour limit) when nothing has changed. Never throws —
|
|
11
|
+
* network errors surface as `status: 0`.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchCommits(repo, opts = {}) {
|
|
14
|
+
const perPage = opts.perPage ?? 10;
|
|
15
|
+
const url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
|
|
16
|
+
const headers = {
|
|
17
|
+
Accept: 'application/vnd.github+json',
|
|
18
|
+
'User-Agent': 'nocville-npc',
|
|
19
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
20
|
+
};
|
|
21
|
+
if (opts.token)
|
|
22
|
+
headers['Authorization'] = `Bearer ${opts.token}`;
|
|
23
|
+
if (opts.etag)
|
|
24
|
+
headers['If-None-Match'] = opts.etag;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url, { headers, signal: controller.signal });
|
|
29
|
+
const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
|
|
30
|
+
const etag = res.headers.get('etag');
|
|
31
|
+
if (res.status === 304) {
|
|
32
|
+
return { notModified: true, commits: [], etag: opts.etag ?? null, rateRemaining, status: 304 };
|
|
33
|
+
}
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
// Drain the body so the socket can be reused.
|
|
36
|
+
await res.text().catch(() => undefined);
|
|
37
|
+
return { notModified: false, commits: [], etag, rateRemaining, status: res.status };
|
|
38
|
+
}
|
|
39
|
+
const body = (await res.json());
|
|
40
|
+
const commits = body.map((c) => parseCommit(c));
|
|
41
|
+
return { notModified: false, commits, etag, rateRemaining, status: res.status };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { notModified: false, commits: [], etag: null, rateRemaining: null, status: 0 };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseCommit(c) {
|
|
51
|
+
const commit = c['commit'] ?? {};
|
|
52
|
+
const commitAuthor = commit['author'] ?? {};
|
|
53
|
+
const ghAuthor = c['author'] ?? {};
|
|
54
|
+
const message = String(commit['message'] ?? '').split('\n')[0] ?? '';
|
|
55
|
+
return {
|
|
56
|
+
sha: String(c['sha'] ?? ''),
|
|
57
|
+
author: String(commitAuthor['name'] ?? ghAuthor['login'] ?? 'unknown'),
|
|
58
|
+
message,
|
|
59
|
+
date: String(commitAuthor['date'] ?? ''),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Count how many commits landed within the last `hours`. */
|
|
63
|
+
export function commitsWithin(commits, hours, now = Date.now()) {
|
|
64
|
+
const cutoff = now - hours * 3600_000;
|
|
65
|
+
return commits.filter((c) => {
|
|
66
|
+
const t = Date.parse(c.date);
|
|
67
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
68
|
+
}).length;
|
|
69
|
+
}
|
|
70
|
+
/** Short relative-time string like "3m ago", "2h ago", "1d ago". */
|
|
71
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
72
|
+
const t = Date.parse(iso);
|
|
73
|
+
if (!Number.isFinite(t))
|
|
74
|
+
return 'unknown';
|
|
75
|
+
const secs = Math.max(0, Math.round((now - t) / 1000));
|
|
76
|
+
if (secs < 60)
|
|
77
|
+
return `${secs}s ago`;
|
|
78
|
+
const mins = Math.round(secs / 60);
|
|
79
|
+
if (mins < 60)
|
|
80
|
+
return `${mins}m ago`;
|
|
81
|
+
const hrs = Math.round(mins / 60);
|
|
82
|
+
if (hrs < 24)
|
|
83
|
+
return `${hrs}h ago`;
|
|
84
|
+
const days = Math.round(hrs / 24);
|
|
85
|
+
return `${days}d ago`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Return the commits newer than `lastSeenSha`, newest-first. When the sha isn't
|
|
89
|
+
* present in the window (more than a page of new commits, or rewritten history)
|
|
90
|
+
* every fetched commit is treated as new. An undefined sha means "not seeded
|
|
91
|
+
* yet" — returns empty so the caller can seed without a spurious announcement.
|
|
92
|
+
*/
|
|
93
|
+
export function newCommitsSince(commits, lastSeenSha) {
|
|
94
|
+
if (!lastSeenSha)
|
|
95
|
+
return [];
|
|
96
|
+
const idx = commits.findIndex((c) => c.sha === lastSeenSha);
|
|
97
|
+
if (idx === 0)
|
|
98
|
+
return []; // newest is already seen
|
|
99
|
+
if (idx === -1)
|
|
100
|
+
return commits; // fell out of the window — treat all as new
|
|
101
|
+
return commits.slice(0, idx);
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=github.js.map
|