@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.
Files changed (40) hide show
  1. package/README.md +36 -15
  2. package/dist/cjs/cli.js +44 -8
  3. package/dist/cjs/core/base-npc.js +7 -0
  4. package/dist/cjs/core/polling-npc.js +8 -0
  5. package/dist/cjs/index.js +13 -1
  6. package/dist/cjs/npcs/agent-common/shim.js +8 -2
  7. package/dist/cjs/npcs/github-watcher/github.js +109 -0
  8. package/dist/cjs/npcs/office-characters/index.js +102 -0
  9. package/dist/cjs/npcs/process-watcher/index.js +0 -1
  10. package/dist/cjs/npcs/process-watcher/process-scanner.js +42 -4
  11. package/dist/cjs/npcs/receptionist/index.js +6 -119
  12. package/dist/cjs/npcs/security/index.js +26 -0
  13. package/dist/cjs/npcs/web-page-watcher/basic.js +58 -0
  14. package/dist/cjs/npcs/web-page-watcher/capture.js +26 -0
  15. package/dist/cjs/npcs/web-page-watcher/chrome.js +62 -0
  16. package/dist/cli.js +44 -8
  17. package/dist/core/base-npc.js +7 -0
  18. package/dist/core/polling-npc.d.ts +3 -0
  19. package/dist/core/polling-npc.js +8 -0
  20. package/dist/core/types.d.ts +5 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +2 -0
  23. package/dist/npcs/agent-common/shim.js +8 -2
  24. package/dist/npcs/github-watcher/github.d.ts +39 -0
  25. package/dist/npcs/github-watcher/github.js +103 -0
  26. package/dist/npcs/office-characters/index.d.ts +55 -0
  27. package/dist/npcs/office-characters/index.js +91 -0
  28. package/dist/npcs/process-watcher/index.js +1 -2
  29. package/dist/npcs/process-watcher/process-scanner.d.ts +3 -1
  30. package/dist/npcs/process-watcher/process-scanner.js +41 -4
  31. package/dist/npcs/receptionist/index.d.ts +2 -21
  32. package/dist/npcs/receptionist/index.js +6 -119
  33. package/dist/npcs/security/index.d.ts +12 -0
  34. package/dist/npcs/security/index.js +22 -0
  35. package/dist/npcs/web-page-watcher/basic.d.ts +16 -0
  36. package/dist/npcs/web-page-watcher/basic.js +52 -0
  37. package/dist/npcs/web-page-watcher/capture.js +26 -0
  38. package/dist/npcs/web-page-watcher/chrome.d.ts +46 -0
  39. package/dist/npcs/web-page-watcher/chrome.js +57 -0
  40. 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 ?? defaults_js_1.DEFAULTS.pollInterval });
22
- if ((config.lat === undefined) !== (config.lon === undefined)) {
23
- throw new errors_js_1.NpcConfigError('--lat and --lon must be given together');
24
- }
25
- if (config.lat !== undefined && !(0, weather_js_1.validCoords)(config.lat, config.lon)) {
26
- throw new errors_js_1.NpcConfigError('--lat must be within ±90 and --lon within ±180');
27
- }
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
- home: { near: 'entrance' },
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
- await this.refreshWeather();
63
- const now = new Date();
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 Greeter showing time + local weather
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
- Receptionist Options:
106
- --poll-interval=<ms> Clock tick in ms (default: 60000)
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) {
@@ -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;
@@ -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();
@@ -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
- writeChanged(SHIM_PATH() + '.cmd', WINDOWS_SHIM.replace(/\n/g, '\r\n'), 0o755);
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