@nocville/npc 0.3.0 → 0.4.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.
Files changed (38) hide show
  1. package/README.md +38 -18
  2. package/dist/cjs/cli.js +24 -3
  3. package/dist/cjs/core/base-npc.js +1 -0
  4. package/dist/cjs/core/config.js +23 -1
  5. package/dist/cjs/core/polling-npc.js +8 -0
  6. package/dist/cjs/core/user-config.js +2 -0
  7. package/dist/cjs/doctor.js +19 -0
  8. package/dist/cjs/index.js +2 -1
  9. package/dist/cjs/npcs/agent-common/session.js +3 -0
  10. package/dist/cjs/npcs/codex/watcher.js +1 -0
  11. package/dist/cjs/npcs/github-watcher/github.js +109 -0
  12. package/dist/cjs/npcs/office-characters/index.js +11 -1
  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 +24 -3
  17. package/dist/core/base-npc.js +1 -0
  18. package/dist/core/config.js +23 -1
  19. package/dist/core/polling-npc.d.ts +3 -0
  20. package/dist/core/polling-npc.js +8 -0
  21. package/dist/core/types.d.ts +2 -0
  22. package/dist/core/user-config.d.ts +1 -0
  23. package/dist/core/user-config.js +2 -0
  24. package/dist/doctor.js +19 -0
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/npcs/agent-common/session.js +3 -0
  28. package/dist/npcs/codex/watcher.js +1 -0
  29. package/dist/npcs/github-watcher/github.d.ts +39 -0
  30. package/dist/npcs/github-watcher/github.js +103 -0
  31. package/dist/npcs/office-characters/index.d.ts +5 -0
  32. package/dist/npcs/office-characters/index.js +9 -0
  33. package/dist/npcs/web-page-watcher/basic.d.ts +16 -0
  34. package/dist/npcs/web-page-watcher/basic.js +52 -0
  35. package/dist/npcs/web-page-watcher/capture.js +26 -0
  36. package/dist/npcs/web-page-watcher/chrome.d.ts +46 -0
  37. package/dist/npcs/web-page-watcher/chrome.js +57 -0
  38. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -56,10 +56,12 @@ Monitoring NPC types:
56
56
  plant-caretaker Waters plants and reviews their performance
57
57
  it-technician Inspects tech gear and recommends reboots
58
58
  out-of-touch-executive Interrupts coworkers between long lounge breaks
59
+ slightly-inappropriate-hr Desk-based HR with questionable people skills
59
60
  github-watcher Call out new commits on a repo
60
61
  web-page-watcher Render a watched web page as a pixel-art TV
61
62
 
62
63
  Common Options:
64
+ --workspace=<file> Verified producer workspace settings from team privacy settings.
63
65
  --api-key=<key> Team API key (or NOCVILLE_API_KEY env)
64
66
  --api-url=<url> Nocville API URL (or NOCVILLE_API_URL env).
65
67
  Default: https://nocville.com. For local dev use
@@ -382,7 +384,8 @@ async function main() {
382
384
  case 'office-dog':
383
385
  case 'plant-caretaker':
384
386
  case 'it-technician':
385
- case 'out-of-touch-executive': {
387
+ case 'out-of-touch-executive':
388
+ case 'slightly-inappropriate-hr': {
386
389
  if (!config.serviceId)
387
390
  config.serviceId = parsed.command;
388
391
  const { OFFICE_CHARACTER_NPCS } = await import('./npcs/office-characters/index.js');
@@ -475,7 +478,20 @@ async function runInit(flags, config) {
475
478
  let apiKey = stringFlag(flags, 'api-key');
476
479
  const apiUrl = stringFlag(flags, 'api-url');
477
480
  const map = stringFlag(flags, 'map');
478
- if (!apiKey && !apiUrl && !map) {
481
+ const workspace = config.workspace;
482
+ if (workspace)
483
+ apiKey = apiKey || config.apiKey;
484
+ if (workspace && !apiKey) {
485
+ const { resolveApiKey } = await import('./core/api-key.js');
486
+ const resolved = await resolveApiKey({ flags });
487
+ if (!resolved.ok) {
488
+ console.error(resolved.error);
489
+ process.exitCode = 1;
490
+ return;
491
+ }
492
+ apiKey = resolved.key;
493
+ }
494
+ if (!apiKey && !apiUrl && !map && !workspace) {
479
495
  const saved = loadUserConfig();
480
496
  if (saved.apiKey && !process.env['NOCVILLE_API_KEY']?.trim()) {
481
497
  console.log('Usage: nocville-npc init --api-key=<key> [--api-url=<url>] [--map=<name>]');
@@ -491,7 +507,12 @@ async function runInit(flags, config) {
491
507
  }
492
508
  apiKey = resolved.key;
493
509
  }
494
- const path = saveUserConfig({ apiKey, apiUrl, map });
510
+ const path = saveUserConfig({
511
+ apiKey,
512
+ apiUrl: workspace ? config.apiUrl : apiUrl,
513
+ map: workspace ? config.map : map,
514
+ workspace,
515
+ });
495
516
  const saved = loadUserConfig();
496
517
  console.log(`✓ Saved ${path} (mode 0600)`);
497
518
  console.log(` API: ${saved.apiUrl ?? 'https://nocville.com'} key: ${maskKey(saved.apiKey)}`);
@@ -14,6 +14,7 @@ export class BaseNPC {
14
14
  // We can't access `this.name` here since it's abstract, so use a placeholder
15
15
  this.logger = new Logger('npc', config.debug);
16
16
  this.client = new NocvilleClient({
17
+ workspace: config.workspace,
17
18
  apiUrl: config.apiUrl,
18
19
  apiKey: config.apiKey,
19
20
  serviceId: config.serviceId,
@@ -1,3 +1,5 @@
1
+ import { loadProducerWorkspace } from '@nocville/node';
2
+ import { resolve } from 'node:path';
1
3
  import { loadUserConfig } from './user-config.js';
2
4
  /**
3
5
  * Simple hand-rolled CLI arg parser.
@@ -48,9 +50,16 @@ export function parseArgs(argv) {
48
50
  */
49
51
  export function resolveConfig(flags, overrides) {
50
52
  const saved = loadUserConfig();
53
+ const workspacePath = overrides?.workspace ||
54
+ stringFlag(flags, 'workspace') ||
55
+ process.env['NOCVILLE_WORKSPACE'] ||
56
+ saved.workspace;
57
+ const workspace = workspacePath ? resolve(workspacePath) : undefined;
58
+ const enrollment = workspace ? loadProducerWorkspace(workspace) : undefined;
51
59
  const apiUrl = overrides?.apiUrl ||
52
60
  stringFlag(flags, 'api-url') ||
53
61
  process.env['NOCVILLE_API_URL'] ||
62
+ enrollment?.apiUrl ||
54
63
  saved.apiUrl ||
55
64
  'https://nocville.com';
56
65
  const apiKey = overrides?.apiKey ||
@@ -68,10 +77,23 @@ export function resolveConfig(flags, overrides) {
68
77
  const map = overrides?.map ||
69
78
  stringFlag(flags, 'map') ||
70
79
  process.env['NOCVILLE_MAP'] ||
80
+ enrollment?.encryption.mapId ||
71
81
  saved.map ||
72
82
  undefined;
73
83
  const name = overrides?.name || stringFlag(flags, 'name') || process.env['NOCVILLE_NAME'] || undefined;
74
- return { apiUrl, apiKey, serviceId, debug, map, name };
84
+ if (enrollment && apiUrl.replace(/\/+$/, '') !== enrollment.apiUrl)
85
+ throw new Error('API origin does not match the verified workspace');
86
+ if (enrollment && map && map !== enrollment.encryption.mapId)
87
+ throw new Error('Encrypted producers use the map ID in their workspace settings');
88
+ return {
89
+ apiUrl,
90
+ apiKey,
91
+ serviceId,
92
+ debug,
93
+ map: enrollment?.encryption.mapId ?? map,
94
+ name,
95
+ ...(workspace ? { workspace } : {}),
96
+ };
75
97
  }
76
98
  export function stringFlag(flags, key) {
77
99
  const val = flags[key];
@@ -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();
@@ -6,6 +6,8 @@ export type Archetype = 'service' | 'database' | 'trading' | 'ai-agent' | 'pipel
6
6
  /** A companion declaration is the SDK's CompanionSpec. */
7
7
  export type CompanionDecl = CompanionSpec;
8
8
  export interface NpcConfig {
9
+ /** Local verified workspace enrollment bundle. */
10
+ workspace?: string;
9
11
  apiUrl: string;
10
12
  apiKey: string;
11
13
  serviceId: string;
@@ -4,6 +4,7 @@
4
4
  * has to appear in ~/.claude/settings.json, ~/.codex/hooks.json or `ps` output.
5
5
  */
6
6
  export interface UserConfig {
7
+ workspace?: string;
7
8
  apiUrl?: string;
8
9
  apiKey?: string;
9
10
  map?: string;
@@ -18,6 +18,8 @@ export function loadUserConfig(path = userConfigPath()) {
18
18
  try {
19
19
  const raw = JSON.parse(readFileSync(path, 'utf-8'));
20
20
  const out = {};
21
+ if (typeof raw['workspace'] === 'string' && raw['workspace'])
22
+ out.workspace = raw['workspace'];
21
23
  if (typeof raw['apiUrl'] === 'string' && raw['apiUrl'])
22
24
  out.apiUrl = raw['apiUrl'];
23
25
  if (typeof raw['apiKey'] === 'string' && raw['apiKey'])
package/dist/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { NocvilleClient } from '@nocville/node';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { readdirSync } from 'node:fs';
3
4
  import { isAgentAlive } from './npcs/agent-common/liveness.js';
@@ -39,6 +40,24 @@ export async function runDoctor(config) {
39
40
  if (!hasKey) {
40
41
  console.log(' [SKIP] POST /api/v1/status — no --api-key / NOCVILLE_API_KEY set');
41
42
  }
43
+ else if (config.workspace || config.apiKey.includes('.team_')) {
44
+ // Exercise the locally encrypted path; never send a plaintext probe to a v2 workspace.
45
+ try {
46
+ const client = new NocvilleClient({
47
+ workspace: config.workspace,
48
+ apiUrl: config.apiUrl,
49
+ apiKey: config.apiKey,
50
+ serviceId: 'nocville-doctor',
51
+ retries: 0,
52
+ });
53
+ const result = await client.pushStatus({ status: 'unknown', offline: true });
54
+ pushOk = result.success;
55
+ console.log(` [${pushOk ? 'OK' : 'FAIL'}] Encrypted producer: ${result.error ?? 'accepted'}`);
56
+ }
57
+ catch (error) {
58
+ console.log(` [FAIL] Encrypted producer: ${error.message}`);
59
+ }
60
+ }
42
61
  else {
43
62
  const start = Date.now();
44
63
  const t = withTimeout(5000);
package/dist/index.d.ts CHANGED
@@ -42,5 +42,5 @@ export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent
42
42
  export type { CursorSetupOptions } from './npcs/cursor/index.js';
43
43
  export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
44
44
  export { SecurityNPC } from './npcs/security/index.js';
45
- export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
45
+ export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
46
46
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -26,5 +26,5 @@ export { GEMINI_DEFAULTS, GEMINI_ADAPTER, geminiTransition, geminiServiceId, han
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
28
  export { SecurityNPC } from './npcs/security/index.js';
29
- export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
29
+ export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
30
30
  //# sourceMappingURL=index.js.map
@@ -241,6 +241,7 @@ export function buildAgentPayload(adapter, state, config) {
241
241
  }
242
242
  async function defaultPush(config, serviceId, payload, timeoutMs) {
243
243
  const client = new NocvilleClient({
244
+ workspace: config.workspace,
244
245
  apiUrl: config.apiUrl,
245
246
  apiKey: config.apiKey,
246
247
  serviceId,
@@ -359,6 +360,8 @@ export function spawnDetachedAgent(adapter, config, args, cliPath = process.argv
359
360
  if (!cliPath)
360
361
  return undefined;
361
362
  const env = { ...process.env };
363
+ if (config.workspace)
364
+ env['NOCVILLE_WORKSPACE'] = config.workspace;
362
365
  env['NOCVILLE_API_URL'] = config.apiUrl;
363
366
  env['NOCVILLE_API_KEY'] = config.apiKey;
364
367
  if (config.map)
@@ -335,6 +335,7 @@ export class CodexWatcher {
335
335
  let client = this.clients.get(serviceId);
336
336
  if (!client) {
337
337
  client = new NocvilleClient({
338
+ workspace: this.config.workspace,
338
339
  apiUrl: this.config.apiUrl,
339
340
  apiKey: this.config.apiKey,
340
341
  serviceId,
@@ -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
@@ -37,6 +37,10 @@ export declare class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
37
37
  get name(): 'out-of-touch-executive';
38
38
  protected get defaultDisplayName(): string;
39
39
  }
40
+ export declare class SlightlyInappropriateHRNPC extends OfficeCharacterNPC {
41
+ get name(): 'slightly-inappropriate-hr';
42
+ protected get defaultDisplayName(): string;
43
+ }
40
44
  export declare const OFFICE_CHARACTER_NPCS: {
41
45
  readonly janitor: typeof JanitorNPC;
42
46
  readonly barista: typeof BaristaNPC;
@@ -45,6 +49,7 @@ export declare const OFFICE_CHARACTER_NPCS: {
45
49
  readonly 'plant-caretaker': typeof PlantCaretakerNPC;
46
50
  readonly 'it-technician': typeof ITTechnicianNPC;
47
51
  readonly 'out-of-touch-executive': typeof OutOfTouchExecutiveNPC;
52
+ readonly 'slightly-inappropriate-hr': typeof SlightlyInappropriateHRNPC;
48
53
  };
49
54
  export {};
50
55
  //# sourceMappingURL=index.d.ts.map
@@ -70,6 +70,14 @@ export class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
70
70
  return 'Out-of-Touch Executive';
71
71
  }
72
72
  }
73
+ export class SlightlyInappropriateHRNPC extends OfficeCharacterNPC {
74
+ get name() {
75
+ return 'slightly-inappropriate-hr';
76
+ }
77
+ get defaultDisplayName() {
78
+ return 'Slightly Inappropriate HR';
79
+ }
80
+ }
73
81
  export const OFFICE_CHARACTER_NPCS = {
74
82
  janitor: JanitorNPC,
75
83
  barista: BaristaNPC,
@@ -78,5 +86,6 @@ export const OFFICE_CHARACTER_NPCS = {
78
86
  'plant-caretaker': PlantCaretakerNPC,
79
87
  'it-technician': ITTechnicianNPC,
80
88
  'out-of-touch-executive': OutOfTouchExecutiveNPC,
89
+ 'slightly-inappropriate-hr': SlightlyInappropriateHRNPC,
81
90
  };
82
91
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ import type { Status } from '../../core/types.js';
2
+ export interface BasicCheck {
3
+ status: number;
4
+ title: string;
5
+ ok: boolean;
6
+ timedOut: boolean;
7
+ }
8
+ /** Extract the <title> text from an HTML string (empty if none). */
9
+ export declare function extractTitle(html: string): string;
10
+ /** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
11
+ export declare function statusFromHttp(code: number, timedOut?: boolean): Status;
12
+ /** Fetch a page and extract HTTP status + <title> (basic, no browser). */
13
+ export declare function basicCheck(url: string, timeoutMs?: number): Promise<BasicCheck>;
14
+ /** Best-effort hostname for a URL, falling back to the raw string. */
15
+ export declare function hostnameOf(url: string): string;
16
+ //# sourceMappingURL=basic.d.ts.map
@@ -0,0 +1,52 @@
1
+ /** Extract the <title> text from an HTML string (empty if none). */
2
+ export function extractTitle(html) {
3
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
4
+ return m ? m[1].replace(/\s+/g, ' ').trim().slice(0, 80) : '';
5
+ }
6
+ /** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
7
+ export function statusFromHttp(code, timedOut = false) {
8
+ if (code >= 200 && code < 400)
9
+ return 'healthy';
10
+ if (code >= 400 && code < 500)
11
+ return 'warning';
12
+ if (code >= 500)
13
+ return 'critical';
14
+ return timedOut ? 'warning' : 'critical'; // 0 = network error
15
+ }
16
+ /** Fetch a page and extract HTTP status + <title> (basic, no browser). */
17
+ export async function basicCheck(url, timeoutMs = 8000) {
18
+ const controller = new AbortController();
19
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
20
+ try {
21
+ const res = await fetch(url, {
22
+ signal: controller.signal,
23
+ headers: { 'User-Agent': 'nocville-npc' },
24
+ redirect: 'follow',
25
+ });
26
+ let title = '';
27
+ try {
28
+ title = extractTitle(await res.text());
29
+ }
30
+ catch {
31
+ // body unavailable; leave title blank
32
+ }
33
+ return { status: res.status, title, ok: res.ok, timedOut: false };
34
+ }
35
+ catch (err) {
36
+ const timedOut = err instanceof Error && err.name === 'AbortError';
37
+ return { status: 0, title: '', ok: false, timedOut };
38
+ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ /** Best-effort hostname for a URL, falling back to the raw string. */
44
+ export function hostnameOf(url) {
45
+ try {
46
+ return new URL(url).hostname;
47
+ }
48
+ catch {
49
+ return url.slice(0, 40);
50
+ }
51
+ }
52
+ //# sourceMappingURL=basic.js.map
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { execFileSync } from 'node:child_process';
3
4
  const MAC_PATHS = [
@@ -43,6 +44,25 @@ export function findChrome(explicit) {
43
44
  return p;
44
45
  return null;
45
46
  }
47
+ if (process.platform === 'win32') {
48
+ const roots = [
49
+ process.env['LOCALAPPDATA'],
50
+ process.env['PROGRAMFILES'],
51
+ process.env['PROGRAMFILES(X86)'],
52
+ ].filter((p) => !!p);
53
+ for (const root of roots)
54
+ for (const relative of [
55
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
56
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
57
+ ['Chromium', 'Application', 'chrome.exe'],
58
+ ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
59
+ ]) {
60
+ const path = join(root, ...relative);
61
+ if (existsSync(path))
62
+ return path;
63
+ }
64
+ return null;
65
+ }
46
66
  if (process.platform === 'linux') {
47
67
  for (const bin of LINUX_BINARIES) {
48
68
  const resolved = which(bin);
@@ -76,6 +96,12 @@ export function runningAsRoot() {
76
96
  */
77
97
  export async function loadPuppeteer() {
78
98
  try {
99
+ // Prefer CommonJS in bundled runtimes so Electron resolves through its
100
+ // ASAR loader. Native ESM installations use the optional dynamic import below.
101
+ if (typeof require === 'function') {
102
+ const mod = require('puppeteer-core');
103
+ return mod.default ?? mod;
104
+ }
79
105
  const dynImport = new Function('m', 'return import(m)');
80
106
  const mod = await dynImport('puppeteer-core');
81
107
  return (mod.default ?? mod);
@@ -0,0 +1,46 @@
1
+ export interface PuppeteerViewport {
2
+ width: number;
3
+ height: number;
4
+ deviceScaleFactor?: number;
5
+ }
6
+ export interface PuppeteerResponse {
7
+ status(): number;
8
+ }
9
+ export interface PuppeteerPage {
10
+ setViewport(v: PuppeteerViewport): Promise<void>;
11
+ goto(url: string, opts?: {
12
+ waitUntil?: string;
13
+ timeout?: number;
14
+ }): Promise<PuppeteerResponse | null>;
15
+ screenshot(opts: {
16
+ type?: string;
17
+ fullPage?: boolean;
18
+ }): Promise<Buffer | Uint8Array>;
19
+ close(): Promise<void>;
20
+ }
21
+ export interface PuppeteerBrowser {
22
+ newPage(): Promise<PuppeteerPage>;
23
+ close(): Promise<void>;
24
+ /** puppeteer >= 20 exposes `connected`; older versions expose isConnected(). */
25
+ connected?: boolean;
26
+ isConnected?(): boolean;
27
+ on(event: string, cb: () => void): void;
28
+ }
29
+ export interface PuppeteerModule {
30
+ launch(opts: {
31
+ executablePath?: string;
32
+ headless?: boolean | 'new';
33
+ args?: string[];
34
+ }): Promise<PuppeteerBrowser>;
35
+ }
36
+ /** Find a usable Chrome/Chromium executable: explicit path → env → known paths. */
37
+ export declare function findChrome(explicit?: string): string | null;
38
+ /**
39
+ * Load the optional `puppeteer-core` peer dependency without a static import,
40
+ * so the package builds and runs when it isn't installed. Returns null if the
41
+ * module is absent.
42
+ */
43
+ export declare function loadPuppeteer(): Promise<PuppeteerModule | null>;
44
+ /** True if the browser reports a live connection. */
45
+ export declare function browserConnected(browser: PuppeteerBrowser): boolean;
46
+ //# sourceMappingURL=chrome.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { existsSync } from 'node:fs';
2
+ const CHROME_PATHS = {
3
+ darwin: [
4
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
5
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
6
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
7
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
8
+ ],
9
+ linux: [
10
+ '/usr/bin/google-chrome',
11
+ '/usr/bin/google-chrome-stable',
12
+ '/usr/bin/chromium',
13
+ '/usr/bin/chromium-browser',
14
+ '/usr/bin/microsoft-edge',
15
+ '/snap/bin/chromium',
16
+ ],
17
+ };
18
+ /** Find a usable Chrome/Chromium executable: explicit path → env → known paths. */
19
+ export function findChrome(explicit) {
20
+ const envPath = process.env['CHROME_PATH'];
21
+ const candidates = [
22
+ ...(explicit ? [explicit] : []),
23
+ ...(envPath ? [envPath] : []),
24
+ ...(CHROME_PATHS[process.platform] ?? []),
25
+ ];
26
+ for (const p of candidates) {
27
+ if (p && existsSync(p))
28
+ return p;
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * Load the optional `puppeteer-core` peer dependency without a static import,
34
+ * so the package builds and runs when it isn't installed. Returns null if the
35
+ * module is absent.
36
+ */
37
+ export async function loadPuppeteer() {
38
+ try {
39
+ // Indirect dynamic import so TypeScript/bundlers don't require the module
40
+ // at build time.
41
+ const dynImport = new Function('m', 'return import(m)');
42
+ const mod = await dynImport('puppeteer-core');
43
+ return (mod.default ?? mod);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /** True if the browser reports a live connection. */
50
+ export function browserConnected(browser) {
51
+ if (typeof browser.connected === 'boolean')
52
+ return browser.connected;
53
+ if (typeof browser.isConnected === 'function')
54
+ return browser.isConnected();
55
+ return true;
56
+ }
57
+ //# sourceMappingURL=chrome.js.map