@nocville/npc 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +23 -5
  2. package/dist/cjs/cli.js +55 -34
  3. package/dist/cjs/core/base-npc.js +8 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +10 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  14. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  15. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  16. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  17. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  18. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  19. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  20. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  21. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  22. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  23. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  24. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  25. package/dist/cjs/npcs/receptionist/index.js +45 -19
  26. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  27. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  28. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  29. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  30. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  31. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  32. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  33. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  34. package/dist/cli.js +56 -35
  35. package/dist/core/base-npc.d.ts +5 -0
  36. package/dist/core/base-npc.js +8 -0
  37. package/dist/core/config.d.ts +5 -0
  38. package/dist/core/config.js +18 -2
  39. package/dist/core/errors.d.ts +11 -0
  40. package/dist/core/errors.js +18 -0
  41. package/dist/core/http.d.ts +52 -0
  42. package/dist/core/http.js +111 -0
  43. package/dist/core/pixel-art.d.ts +38 -0
  44. package/dist/core/pixel-art.js +105 -0
  45. package/dist/core/polling-npc.d.ts +22 -1
  46. package/dist/core/polling-npc.js +98 -13
  47. package/dist/core/text.d.ts +6 -0
  48. package/dist/core/text.js +12 -0
  49. package/dist/core/thresholds.d.ts +9 -1
  50. package/dist/core/thresholds.js +27 -0
  51. package/dist/index.d.ts +5 -2
  52. package/dist/index.js +4 -2
  53. package/dist/npcs/agent-common/session.js +1 -0
  54. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  55. package/dist/npcs/github-watcher/defaults.js +7 -1
  56. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  57. package/dist/npcs/github-watcher/github-api.js +80 -45
  58. package/dist/npcs/github-watcher/index.d.ts +6 -0
  59. package/dist/npcs/github-watcher/index.js +83 -37
  60. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  61. package/dist/npcs/internet-connectivity/checks.js +70 -67
  62. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  63. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  64. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  65. package/dist/npcs/internet-connectivity/index.js +135 -133
  66. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  67. package/dist/npcs/log-watcher/buffer.js +58 -0
  68. package/dist/npcs/log-watcher/index.d.ts +22 -21
  69. package/dist/npcs/log-watcher/index.js +112 -106
  70. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  71. package/dist/npcs/log-watcher/tail.js +101 -54
  72. package/dist/npcs/process-watcher/index.d.ts +1 -1
  73. package/dist/npcs/process-watcher/index.js +62 -52
  74. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  75. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  76. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  77. package/dist/npcs/receptionist/defaults.js +4 -0
  78. package/dist/npcs/receptionist/index.d.ts +12 -2
  79. package/dist/npcs/receptionist/index.js +46 -20
  80. package/dist/npcs/receptionist/weather.d.ts +8 -6
  81. package/dist/npcs/receptionist/weather.js +39 -45
  82. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  83. package/dist/npcs/system-monitor/collectors.js +64 -17
  84. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  85. package/dist/npcs/system-monitor/index.d.ts +2 -1
  86. package/dist/npcs/system-monitor/index.js +54 -30
  87. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  88. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  89. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  90. package/dist/npcs/web-page-watcher/capture.js +21 -1
  91. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  92. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  93. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  94. package/dist/npcs/web-page-watcher/index.js +121 -70
  95. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  96. package/package.json +2 -2
@@ -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
- collecting = false;
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
- this.pollInterval = config.pollInterval ?? 15000;
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
- // For slow pollers, keep the NPC alive with a lightweight heartbeat between
25
- // collections so it never falsely goes stale mid-interval.
26
- if (this.pollInterval > 60_000) {
27
- this.client.startHeartbeat(30_000);
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.client.stopHeartbeat();
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
- async runCollection() {
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.collecting) {
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.collecting = true;
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
- finally {
57
- this.collecting = false;
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
  }
@@ -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
@@ -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/index.d.ts CHANGED
@@ -5,8 +5,9 @@ export { EventNPC } from './core/event-npc.js';
5
5
  export { Logger } from './core/logger.js';
6
6
  export { parseArgs, resolveConfig, stringFlag, boolFlag, numberFlag } from './core/config.js';
7
7
  export type { ParsedArgs } from './core/config.js';
8
- export { evaluateThreshold, worstStatus } from './core/thresholds.js';
9
- export { truncate } from './core/text.js';
8
+ export { evaluateThreshold, worstStatus, moodForStatus } from './core/thresholds.js';
9
+ export { truncate, sanitizeText } from './core/text.js';
10
+ export { NpcConfigError } from './core/errors.js';
10
11
  export { statusColor, pushHistory } from './core/screen.js';
11
12
  export type { ScreenColor } from './core/screen.js';
12
13
  export type { Status, Mood, NpcConfig, CollectedMetrics, ThresholdConfig, NpcState, LogLevelName, Archetype, BehaviorHints, CompanionSpec, CompanionDecl, ScreenData, ScreenValue, } from './core/types.js';
@@ -18,6 +19,8 @@ export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHo
18
19
  export type { CodexSetupOptions, CodexWatcherOptions, RolloutSignal } from './npcs/codex/index.js';
19
20
  export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
20
21
  export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
22
+ export { encodePixelArt, pixelArtFromPng, pixelDevice } from './core/pixel-art.js';
23
+ export type { PixelArtFrames } from './core/pixel-art.js';
21
24
  export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
22
25
  export type { SessionFilter, SessionRules, PublishDecision } from './core/session-filter.js';
23
26
  export type { UserConfig } from './core/user-config.js';
package/dist/index.js CHANGED
@@ -4,8 +4,9 @@ export { PollingNPC } from './core/polling-npc.js';
4
4
  export { EventNPC } from './core/event-npc.js';
5
5
  export { Logger } from './core/logger.js';
6
6
  export { parseArgs, resolveConfig, stringFlag, boolFlag, numberFlag } from './core/config.js';
7
- export { evaluateThreshold, worstStatus } from './core/thresholds.js';
8
- export { truncate } from './core/text.js';
7
+ export { evaluateThreshold, worstStatus, moodForStatus } from './core/thresholds.js';
8
+ export { truncate, sanitizeText } from './core/text.js';
9
+ export { NpcConfigError } from './core/errors.js';
9
10
  export { statusColor, pushHistory } from './core/screen.js';
10
11
  // NPCs
11
12
  export { SystemMonitorNPC } from './npcs/system-monitor/index.js';
@@ -13,6 +14,7 @@ export { ClaudeCodeNPC, handleClaudeCodeCommand, handleHookEvent, setupHooks, un
13
14
  export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHooks, uninstallCodexHooks, parseRolloutLine, CodexWatcher, } from './npcs/codex/index.js';
14
15
  export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
15
16
  export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
17
+ export { encodePixelArt, pixelArtFromPng, pixelDevice } from './core/pixel-art.js';
16
18
  export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
17
19
  export { InternetConnectivityNPC } from './npcs/internet-connectivity/index.js';
18
20
  export { ProcessWatcherNPC } from './npcs/process-watcher/index.js';
@@ -222,6 +222,7 @@ export function buildAgentPayload(adapter, state, config) {
222
222
  name,
223
223
  message: current ? truncate(current, 120) : undefined,
224
224
  archetype: 'ai-agent',
225
+ kind: adapter.command,
225
226
  metrics: { sessionStartedAt: state.startedAt ?? state.lastUpdate },
226
227
  appearance: { family: adapter.family },
227
228
  agentStats: state.agentStats,
@@ -1,8 +1,14 @@
1
1
  export declare const DEFAULTS: {
2
2
  pollInterval: number;
3
+ /** Without a token GitHub allows 60 requests/hour (304s included): poll every 90s. */
4
+ unauthenticatedPollInterval: number;
3
5
  fetchTimeoutMs: number;
4
6
  perPage: number;
7
+ /** Commit rows on the board (plus a header row). */
8
+ boardRows: number;
5
9
  /** Polls to keep the 'new-commit' sub-status active after a fresh commit. */
6
10
  newCommitPolls: number;
11
+ /** Consecutive network/5xx failures tolerated (board kept, healthy) before warning. */
12
+ failuresBeforeWarning: number;
7
13
  };
8
14
  //# sourceMappingURL=defaults.d.ts.map
@@ -1,8 +1,14 @@
1
1
  export const DEFAULTS = {
2
- pollInterval: 60000, // 60s
2
+ pollInterval: 60000, // 60s with a token
3
+ /** Without a token GitHub allows 60 requests/hour (304s included): poll every 90s. */
4
+ unauthenticatedPollInterval: 90000,
3
5
  fetchTimeoutMs: 5000,
4
6
  perPage: 10,
7
+ /** Commit rows on the board (plus a header row). */
8
+ boardRows: 8,
5
9
  /** Polls to keep the 'new-commit' sub-status active after a fresh commit. */
6
10
  newCommitPolls: 2,
11
+ /** Consecutive network/5xx failures tolerated (board kept, healthy) before warning. */
12
+ failuresBeforeWarning: 3,
7
13
  };
8
14
  //# sourceMappingURL=defaults.js.map
@@ -1,3 +1,4 @@
1
+ import type { ReadableResponse } from '../../core/http.js';
1
2
  export interface CommitInfo {
2
3
  sha: string;
3
4
  author: string;
@@ -5,25 +6,26 @@ export interface CommitInfo {
5
6
  date: string;
6
7
  }
7
8
  export interface FetchCommitsResult {
8
- /** 304 Not Modified — the ETag matched, nothing changed, no rate-limit cost. */
9
+ /** 304 Not Modified — the ETag matched, nothing changed. */
9
10
  notModified: boolean;
10
11
  commits: CommitInfo[];
11
12
  etag: string | null;
12
13
  rateRemaining: number | null;
14
+ /** When the rate-limit window resets (epoch ms), if GitHub said. */
15
+ rateResetAt: number | null;
16
+ /** HTTP status, or 0 for a network error/timeout/unreadable body. */
13
17
  status: number;
14
18
  }
15
19
  /** Minimal fetch signature so tests can inject a stub. */
16
20
  export type FetchFn = (url: string, init?: {
17
21
  headers?: Record<string, string>;
18
22
  signal?: AbortSignal;
19
- }) => Promise<{
23
+ }) => Promise<ReadableResponse & {
20
24
  ok: boolean;
21
25
  status: number;
22
26
  headers: {
23
27
  get(name: string): string | null;
24
28
  };
25
- json(): Promise<unknown>;
26
- text(): Promise<string>;
27
29
  }>;
28
30
  export interface FetchOptions {
29
31
  token?: string;
@@ -34,15 +36,27 @@ export interface FetchOptions {
34
36
  /** Injectable fetch (defaults to the global fetch). */
35
37
  fetchFn?: FetchFn;
36
38
  }
39
+ /**
40
+ * Validate an "owner/name" repository slug. It is interpolated into the API
41
+ * URL path, so anything else (`../`, extra segments, query characters) is
42
+ * rejected rather than letting it — and the token — reach another endpoint.
43
+ */
44
+ export declare function isValidRepo(repo: string): boolean;
37
45
  /** Look up a repo's default branch (one call at startup). Null on failure. */
38
46
  export declare function fetchDefaultBranch(repo: string, opts?: FetchOptions): Promise<string | null>;
39
47
  /**
40
48
  * Fetch recent commits from the GitHub REST API using conditional requests.
41
- * Passing the previous ETag yields a 304 (which does not count against the
42
- * unauthenticated 60/hour limit) when nothing has changed. Never throws —
43
- * network errors surface as `status: 0`.
49
+ * Passing the previous ETag yields a cheap 304 when nothing has changed (free
50
+ * of the rate limit for authenticated requests). Never throws — network
51
+ * errors and unreadable bodies surface as `status: 0`.
44
52
  */
45
53
  export declare function fetchCommits(repo: string, opts?: FetchOptions): Promise<FetchCommitsResult>;
54
+ /**
55
+ * Extract the displayed fields from one (untrusted) commit object: a hex sha,
56
+ * the subject line and author name with control/bidi characters removed and
57
+ * lengths capped. Null for anything without a valid sha.
58
+ */
59
+ export declare function parseCommit(raw: unknown): CommitInfo | null;
46
60
  /** Count how many commits landed within the last `hours`. */
47
61
  export declare function commitsWithin(commits: CommitInfo[], hours: number, now?: number): number;
48
62
  /**
@@ -1,96 +1,131 @@
1
+ import { NPC_USER_AGENT, asRecord, readTextCapped } from '../../core/http.js';
2
+ import { sanitizeText } from '../../core/text.js';
3
+ /** Largest GitHub API response we read (10 commits are ~30-60 KB). */
4
+ const MAX_BODY_BYTES = 512 * 1024;
5
+ // GitHub owner and repository names: letters, digits, '-', '_' and '.'.
6
+ const REPO_RE = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/;
7
+ /**
8
+ * Validate an "owner/name" repository slug. It is interpolated into the API
9
+ * URL path, so anything else (`../`, extra segments, query characters) is
10
+ * rejected rather than letting it — and the token — reach another endpoint.
11
+ */
12
+ export function isValidRepo(repo) {
13
+ const m = repo.match(REPO_RE);
14
+ return !!m && m[2] !== '.' && m[2] !== '..';
15
+ }
1
16
  function toNum(v) {
2
- if (v === null)
17
+ if (v === null || v.trim() === '')
3
18
  return null;
4
19
  const n = Number(v);
5
- return Number.isNaN(n) ? null : n;
20
+ return Number.isFinite(n) ? n : null;
6
21
  }
7
22
  function ghHeaders(opts) {
8
23
  const headers = {
9
24
  Accept: 'application/vnd.github+json',
10
- 'User-Agent': 'nocville-npc',
25
+ 'User-Agent': NPC_USER_AGENT,
11
26
  'X-GitHub-Api-Version': '2022-11-28',
12
27
  };
13
28
  if (opts.token)
14
29
  headers['Authorization'] = `Bearer ${opts.token}`;
15
30
  return headers;
16
31
  }
32
+ function doFetchFn(opts) {
33
+ return opts.fetchFn ?? globalThis.fetch;
34
+ }
35
+ async function readJson(res) {
36
+ const { text, truncated } = await readTextCapped(res, MAX_BODY_BYTES);
37
+ if (truncated)
38
+ throw new Error('response too large');
39
+ return JSON.parse(text);
40
+ }
17
41
  /** Look up a repo's default branch (one call at startup). Null on failure. */
18
42
  export async function fetchDefaultBranch(repo, opts = {}) {
19
- const doFetch = opts.fetchFn ?? globalThis.fetch;
20
- const controller = new AbortController();
21
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
22
43
  try {
23
- const res = await doFetch(`https://api.github.com/repos/${repo}`, {
44
+ const res = await doFetchFn(opts)(`https://api.github.com/repos/${repo}`, {
24
45
  headers: ghHeaders(opts),
25
- signal: controller.signal,
46
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
26
47
  });
27
48
  if (!res.ok)
28
49
  return null;
29
- const body = (await res.json());
30
- return body.default_branch ?? null;
50
+ const branch = asRecord(await readJson(res))?.['default_branch'];
51
+ return typeof branch === 'string' && branch.length > 0 && branch.length <= 255
52
+ ? sanitizeText(branch, 255)
53
+ : null;
31
54
  }
32
55
  catch {
33
56
  return null;
34
57
  }
35
- finally {
36
- clearTimeout(timer);
37
- }
38
58
  }
39
59
  /**
40
60
  * Fetch recent commits from the GitHub REST API using conditional requests.
41
- * Passing the previous ETag yields a 304 (which does not count against the
42
- * unauthenticated 60/hour limit) when nothing has changed. Never throws —
43
- * network errors surface as `status: 0`.
61
+ * Passing the previous ETag yields a cheap 304 when nothing has changed (free
62
+ * of the rate limit for authenticated requests). Never throws — network
63
+ * errors and unreadable bodies surface as `status: 0`.
44
64
  */
45
65
  export async function fetchCommits(repo, opts = {}) {
46
66
  const perPage = opts.perPage ?? 10;
47
- const doFetch = opts.fetchFn ?? globalThis.fetch;
48
67
  let url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
49
68
  if (opts.branch)
50
69
  url += `&sha=${encodeURIComponent(opts.branch)}`;
51
70
  const headers = ghHeaders(opts);
52
71
  if (opts.etag)
53
72
  headers['If-None-Match'] = opts.etag;
54
- const controller = new AbortController();
55
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
73
+ const failed = {
74
+ notModified: false,
75
+ commits: [],
76
+ etag: null,
77
+ rateRemaining: null,
78
+ rateResetAt: null,
79
+ status: 0,
80
+ };
56
81
  try {
57
- const res = await doFetch(url, { headers, signal: controller.signal });
82
+ const res = await doFetchFn(opts)(url, {
83
+ headers,
84
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
85
+ });
58
86
  const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
87
+ const resetSecs = toNum(res.headers.get('x-ratelimit-reset'));
88
+ const rateResetAt = resetSecs === null ? null : resetSecs * 1000;
59
89
  const etag = res.headers.get('etag');
90
+ const meta = { rateRemaining, rateResetAt, status: res.status };
60
91
  if (res.status === 304) {
61
- return {
62
- notModified: true,
63
- commits: [],
64
- etag: opts.etag ?? null,
65
- rateRemaining,
66
- status: 304,
67
- };
92
+ return { ...meta, notModified: true, commits: [], etag: opts.etag ?? null };
68
93
  }
69
94
  if (!res.ok) {
70
- await res.text().catch(() => undefined);
71
- return { notModified: false, commits: [], etag, rateRemaining, status: res.status };
95
+ await res.body?.cancel().catch(() => undefined);
96
+ return { ...meta, notModified: false, commits: [], etag };
72
97
  }
73
- const body = (await res.json());
74
- const commits = body.map((c) => parseCommit(c));
75
- return { notModified: false, commits, etag, rateRemaining, status: res.status };
98
+ const body = await readJson(res);
99
+ if (!Array.isArray(body))
100
+ return failed;
101
+ const commits = body.map(parseCommit).filter((c) => c !== null);
102
+ return { ...meta, notModified: false, commits, etag };
76
103
  }
77
104
  catch {
78
- return { notModified: false, commits: [], etag: null, rateRemaining: null, status: 0 };
79
- }
80
- finally {
81
- clearTimeout(timer);
105
+ return failed;
82
106
  }
83
107
  }
84
- function parseCommit(c) {
85
- const commit = c['commit'] ?? {};
86
- const commitAuthor = commit['author'] ?? {};
87
- const ghAuthor = c['author'] ?? {};
88
- const message = String(commit['message'] ?? '').split('\n')[0] ?? '';
108
+ /**
109
+ * Extract the displayed fields from one (untrusted) commit object: a hex sha,
110
+ * the subject line and author name with control/bidi characters removed and
111
+ * lengths capped. Null for anything without a valid sha.
112
+ */
113
+ export function parseCommit(raw) {
114
+ const c = asRecord(raw);
115
+ const sha = c?.['sha'];
116
+ if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha))
117
+ return null;
118
+ const commit = asRecord(c['commit']) ?? {};
119
+ const commitAuthor = asRecord(commit['author']) ?? {};
120
+ const ghAuthor = asRecord(c['author']) ?? {};
121
+ const str = (v) => (typeof v === 'string' && v.trim() ? v : null);
122
+ const message = str(commit['message'])?.split('\n')[0] ?? '';
123
+ const date = str(commitAuthor['date']) ?? '';
89
124
  return {
90
- sha: String(c['sha'] ?? ''),
91
- author: String(commitAuthor['name'] ?? ghAuthor['login'] ?? 'unknown'),
92
- message,
93
- date: String(commitAuthor['date'] ?? ''),
125
+ sha: sha.toLowerCase(),
126
+ author: sanitizeText(str(commitAuthor['name']) ?? str(ghAuthor['login']) ?? 'unknown', 40),
127
+ message: sanitizeText(message, 200),
128
+ date: Number.isFinite(Date.parse(date)) ? date : '',
94
129
  };
95
130
  }
96
131
  /** Count how many commits landed within the last `hours`. */
@@ -19,6 +19,9 @@ export declare class GitHubWatcherNPC extends PollingNPC {
19
19
  private lastSeenSha;
20
20
  private commits;
21
21
  private rateRemaining;
22
+ /** While rate-limited, don't call GitHub again before this time (epoch ms). */
23
+ private rateLimitedUntil;
24
+ private consecutiveFailures;
22
25
  private newCommitPollsLeft;
23
26
  constructor(config: GitHubWatcherConfig);
24
27
  get name(): string;
@@ -27,6 +30,9 @@ export declare class GitHubWatcherNPC extends PollingNPC {
27
30
  get companion(): CompanionDecl;
28
31
  protected onStart(): Promise<void>;
29
32
  protected collect(): Promise<CollectedMetrics>;
33
+ /** Map an error response to a report, or null when the fetch succeeded. */
34
+ private classifyFailure;
35
+ private rateLimited;
30
36
  private reportNewCommits;
31
37
  private reportIdle;
32
38
  private report;