@link-assistant/hive-mind 2.5.5 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c031b7b: Handle weekly-only Codex usage windows and add Telegram Bot API rolling rate-limit telemetry to `/limits`.
8
+
9
+ ## 2.5.6
10
+
11
+ ### Patch Changes
12
+
13
+ - e501237: Warn when malformed or safety-clamped numeric environment settings fall back to a different effective value.
14
+ - 10ddbca: Document same-session live input support and the Codex app-server integration plan for issue #2057.
15
+
3
16
  ## 2.5.5
4
17
 
5
18
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.5.5",
3
+ "version": "2.6.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Classify raw Codex windows by duration instead of response position.
3
+ *
4
+ * Historically the primary window was five hours and the secondary window was
5
+ * weekly. Weekly-only accounts can put the weekly value in the primary slot.
6
+ */
7
+ export function classifyCodexRateLimitWindows(rateLimit) {
8
+ const primary = rateLimit?.primary_window;
9
+ const secondary = rateLimit?.secondary_window;
10
+ const windows = [primary, secondary].filter(Boolean);
11
+ const hasDurationMetadata = windows.some(window => Number.isFinite(Number(window?.limit_window_seconds)));
12
+
13
+ if (!hasDurationMetadata) {
14
+ return { sessionWindow: primary, weeklyWindow: secondary };
15
+ }
16
+
17
+ const sessionWindow = windows.find(window => {
18
+ const seconds = Number(window?.limit_window_seconds);
19
+ return Number.isFinite(seconds) && seconds > 0 && seconds < 24 * 60 * 60;
20
+ });
21
+ const weeklyWindow = windows.find(window => {
22
+ const seconds = Number(window?.limit_window_seconds);
23
+ return Number.isFinite(seconds) && seconds >= 24 * 60 * 60;
24
+ });
25
+
26
+ return { sessionWindow, weeklyWindow };
27
+ }
@@ -30,23 +30,13 @@ const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.
30
30
  // Use semver package for version comparison (see issue #1146)
31
31
  import semver from 'semver';
32
32
  import { buildClaudeQuietEnv } from './claude-quiet-config.lib.mjs';
33
+ import { clampEnvValue, parseIntegerEnv, parseNumberEnv } from './env-config.lib.mjs';
33
34
 
34
35
  // Import lino for parsing Links Notation format
35
36
  const { lino } = await import('./lino.lib.mjs');
36
37
 
37
- // Helper function to safely parse integers with fallback
38
- const parseIntWithDefault = (envVar, defaultValue) => {
39
- const value = getenv(envVar, defaultValue.toString());
40
- const parsed = parseInt(value);
41
- return isNaN(parsed) ? defaultValue : parsed;
42
- };
43
-
44
- // Helper function to safely parse floats with fallback
45
- const parseFloatWithDefault = (envVar, defaultValue) => {
46
- const value = getenv(envVar, defaultValue.toString());
47
- const parsed = parseFloat(value);
48
- return isNaN(parsed) ? defaultValue : parsed;
49
- };
38
+ const parseIntWithDefault = (envVar, defaultValue) => parseIntegerEnv(envVar, defaultValue);
39
+ const parseFloatWithDefault = (envVar, defaultValue) => parseNumberEnv(envVar, defaultValue);
50
40
 
51
41
  // Timeout configurations (in milliseconds)
52
42
  export const timeouts = {
@@ -737,7 +727,7 @@ export const cacheTtl = {
737
727
  usageApi: parseIntWithDefault('HIVE_MIND_USAGE_API_CACHE_TTL_MS', 13 * 60 * 1000), // 13 minutes
738
728
  // System metrics cache TTL (RAM, CPU, disk). Issue #2015 caps this at
739
729
  // 1 minute so queue decisions do not use stale host pressure data.
740
- system: Math.min(parseIntWithDefault('HIVE_MIND_SYSTEM_CACHE_TTL_MS', 60 * 1000), 60 * 1000), // max 1 minute
730
+ system: clampEnvValue('HIVE_MIND_SYSTEM_CACHE_TTL_MS', parseIntWithDefault('HIVE_MIND_SYSTEM_CACHE_TTL_MS', 60 * 1000), { maximum: 60 * 1000 }), // max 1 minute
741
731
  };
742
732
 
743
733
  // File and path configurations
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Parse numeric environment configuration without silently accepting malformed
3
+ * suffixes or discarding explicit operator input.
4
+ */
5
+
6
+ const emittedWarnings = new Set();
7
+
8
+ function warnOnce(key, message) {
9
+ if (emittedWarnings.has(key)) return;
10
+ emittedWarnings.add(key);
11
+ console.warn(message);
12
+ }
13
+
14
+ function readExplicitEnv(envVar) {
15
+ const rawValue = process.env[envVar];
16
+ return rawValue === undefined ? null : rawValue;
17
+ }
18
+
19
+ function warnInvalid(envVar, rawValue, type, defaultValue, scope) {
20
+ warnOnce(`invalid:${envVar}`, `[${scope}] ${envVar}=${JSON.stringify(rawValue)} is not a valid ${type}; using default ${defaultValue}.`);
21
+ }
22
+
23
+ /**
24
+ * Read a base-10 integer environment variable.
25
+ *
26
+ * @param {string} envVar
27
+ * @param {number} defaultValue
28
+ * @param {{scope?: string}} [options]
29
+ * @returns {number}
30
+ */
31
+ export function parseIntegerEnv(envVar, defaultValue, { scope = 'config' } = {}) {
32
+ const rawValue = readExplicitEnv(envVar);
33
+ if (rawValue === null) return defaultValue;
34
+
35
+ const normalized = rawValue.trim();
36
+ if (!/^[+-]?\d+$/.test(normalized)) {
37
+ warnInvalid(envVar, rawValue, 'integer', defaultValue, scope);
38
+ return defaultValue;
39
+ }
40
+
41
+ const parsed = Number(normalized);
42
+ if (!Number.isSafeInteger(parsed)) {
43
+ warnInvalid(envVar, rawValue, 'integer', defaultValue, scope);
44
+ return defaultValue;
45
+ }
46
+
47
+ return parsed;
48
+ }
49
+
50
+ /**
51
+ * Read a finite decimal environment variable.
52
+ *
53
+ * @param {string} envVar
54
+ * @param {number} defaultValue
55
+ * @param {{scope?: string}} [options]
56
+ * @returns {number}
57
+ */
58
+ export function parseNumberEnv(envVar, defaultValue, { scope = 'config' } = {}) {
59
+ const rawValue = readExplicitEnv(envVar);
60
+ if (rawValue === null) return defaultValue;
61
+
62
+ const normalized = rawValue.trim();
63
+ const isDecimal = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized);
64
+ const parsed = Number(normalized);
65
+ if (!isDecimal || !Number.isFinite(parsed)) {
66
+ warnInvalid(envVar, rawValue, 'number', defaultValue, scope);
67
+ return defaultValue;
68
+ }
69
+
70
+ return parsed;
71
+ }
72
+
73
+ /**
74
+ * Apply a safety bound and explain when it changes an explicitly configured
75
+ * environment value.
76
+ *
77
+ * @param {string} envVar
78
+ * @param {number} value
79
+ * @param {{minimum?: number, maximum?: number, scope?: string, hint?: string}} options
80
+ * @returns {number}
81
+ */
82
+ export function clampEnvValue(envVar, value, { minimum, maximum, scope = 'config', hint = '' }) {
83
+ if (minimum !== undefined && value < minimum) {
84
+ if (readExplicitEnv(envVar) !== null) {
85
+ warnOnce(`minimum:${envVar}`, `[${scope}] ${envVar}=${value} is below the minimum (${minimum}); using ${minimum}.${hint ? ` ${hint}` : ''}`);
86
+ }
87
+ return minimum;
88
+ }
89
+
90
+ if (maximum !== undefined && value > maximum) {
91
+ if (readExplicitEnv(envVar) !== null) {
92
+ warnOnce(`maximum:${envVar}`, `[${scope}] ${envVar}=${value} exceeds the maximum (${maximum}); using ${maximum}.${hint ? ` ${hint}` : ''}`);
93
+ }
94
+ return maximum;
95
+ }
96
+
97
+ return value;
98
+ }
@@ -85,6 +85,12 @@ const ENGLISH_LIMITS = {
85
85
  subscription_detail_trial_ends: 'trial ends {{time}}',
86
86
  subscription_detail_trial_ends_in: 'trial ends in {{duration}}; {{time}}',
87
87
  subscription_status: 'Subscription: {{status}}',
88
+ telegram_api: 'Telegram Bot API (local rolling telemetry)',
89
+ telegram_global_window: '{{used}}/{{limit}} messages in 1s',
90
+ telegram_group_window: '{{used}}/{{limit}} messages in busiest group over 1m',
91
+ telegram_last_rate_limit: 'Last 429: {{method}}',
92
+ telegram_rate_limit_responses: '429 responses since startup: {{count}}',
93
+ telegram_retry_in: 'retry in {{seconds}}s',
88
94
  trial_ends: 'Trial ends {{time}}',
89
95
  trial_ends_in: 'Trial ends in {{duration}} ({{time}})',
90
96
  unavailable: 'unavailable',
@@ -12,9 +12,11 @@ import { promisify } from 'node:util';
12
12
  import dayjs from 'dayjs';
13
13
  import utc from 'dayjs/plugin/utc.js';
14
14
 
15
+ import { classifyCodexRateLimitWindows } from './codex-rate-limit-windows.lib.mjs';
15
16
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
17
  import { formatLimitResetsAt, formatLimitResetsIn, formatLocalizedCurrentTime, formatLocalizedRelativeTime, formatLocalizedResetTime, localizeCompactDuration, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
17
18
  import { formatSubscriptionHeading, formatSubscriptionLines, getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo } from './limits-subscription.lib.mjs';
19
+ import { getTelegramRateLimits } from './telegram-rate-limit.lib.mjs';
18
20
  export { getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo };
19
21
  // Initialize dayjs plugins
20
22
  dayjs.extend(utc);
@@ -75,6 +77,15 @@ function mapCodexWindow(window) {
75
77
  };
76
78
  }
77
79
 
80
+ export function mapCodexRateLimitWindows(rateLimit) {
81
+ const { sessionWindow, weeklyWindow } = classifyCodexRateLimitWindows(rateLimit);
82
+
83
+ return {
84
+ currentSession: mapCodexWindow(sessionWindow),
85
+ allModels: mapCodexWindow(weeklyWindow),
86
+ };
87
+ }
88
+
78
89
  export async function readCodexAuth(authPath = DEFAULT_CODEX_AUTH_PATH, verbose = false) {
79
90
  try {
80
91
  const content = await readFile(authPath, 'utf-8');
@@ -936,8 +947,7 @@ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CO
936
947
  }
937
948
 
938
949
  const usage = {
939
- currentSession: mapCodexWindow(data?.rate_limit?.primary_window),
940
- allModels: mapCodexWindow(data?.rate_limit?.secondary_window),
950
+ ...mapCodexRateLimitWindows(data?.rate_limit),
941
951
  sonnetOnly: {
942
952
  percentage: null,
943
953
  resetTime: null,
@@ -949,8 +959,7 @@ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CO
949
959
  ? data.additional_rate_limits.map(limit => ({
950
960
  limitId: limit?.metered_feature || null,
951
961
  limitName: limit?.limit_name || limit?.metered_feature || 'additional',
952
- currentSession: mapCodexWindow(limit?.rate_limit?.primary_window),
953
- allModels: mapCodexWindow(limit?.rate_limit?.secondary_window),
962
+ ...mapCodexRateLimitWindows(limit?.rate_limit),
954
963
  allowed: limit?.rate_limit?.allowed ?? null,
955
964
  limitReached: limit?.rate_limit?.limit_reached ?? null,
956
965
  }))
@@ -1116,6 +1125,24 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1116
1125
  sections.push(section);
1117
1126
  }
1118
1127
 
1128
+ const telegramRateLimit = options?.telegramRateLimit || null;
1129
+ if (telegramRateLimit) {
1130
+ let section = `${lt('telegram_api', {}, { locale })}\n`;
1131
+ const global = telegramRateLimit.global;
1132
+ section += `${getProgressBar(global.usedPercentage)} ${global.usedPercentage}% ${lt('used', {}, { locale })}\n`;
1133
+ section += `${lt('telegram_global_window', { used: global.used, limit: global.limit }, { locale })}\n`;
1134
+ const group = telegramRateLimit.busiestGroup;
1135
+ section += `${getProgressBar(group.usedPercentage)} ${group.usedPercentage}% ${lt('used', {}, { locale })}\n`;
1136
+ section += `${lt('telegram_group_window', { used: group.used, limit: group.limit }, { locale })}\n`;
1137
+ section += `${lt('telegram_rate_limit_responses', { count: telegramRateLimit.rateLimitResponses }, { locale })}\n`;
1138
+ if (telegramRateLimit.lastRateLimit) {
1139
+ const retry = telegramRateLimit.lastRateLimit.retryRemainingSeconds;
1140
+ const retryText = retry === null ? '' : `, ${lt('telegram_retry_in', { seconds: retry }, { locale })}`;
1141
+ section += `${lt('telegram_last_rate_limit', { method: telegramRateLimit.lastRateLimit.method }, { locale })}${retryText}\n`;
1142
+ }
1143
+ sections.push(section);
1144
+ }
1145
+
1119
1146
  const claudeHeading = formatSubscriptionHeading('claude', subscription, { locale });
1120
1147
  const useShortClaudeLabels = Boolean(claudeHeading);
1121
1148
  const claudeSections = [];
@@ -1194,7 +1221,7 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1194
1221
  const sessionSection = formatLimitWindowSection(useTitledLayout ? lt('five_hour_limit_session', {}, { locale }) : lt('codex_5_hour_session', {}, { locale }), usage?.currentSession, 5, DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION, { locale });
1195
1222
  const weeklySection = formatLimitWindowSection(useTitledLayout ? lt('current_week', {}, { locale }) : lt('current_week_all_models', {}, { locale }), usage?.allModels, 168, DISPLAY_THRESHOLDS.CODEX_WEEKLY, { locale });
1196
1223
 
1197
- section += `${sessionSection}\n${weeklySection}`;
1224
+ section += [sessionSection, weeklySection].filter((_, index) => (index === 0 ? hasLimitPercentage(usage?.currentSession) : hasLimitPercentage(usage?.allModels))).join('\n');
1198
1225
 
1199
1226
  const visibleAdditionalRateLimits = additionalRateLimits.filter(limit => hasPositivePercentage(limit.allModels?.percentage));
1200
1227
  if (visibleAdditionalRateLimits.length > 0) {
@@ -1202,9 +1229,10 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1202
1229
  for (const limit of visibleAdditionalRateLimits) {
1203
1230
  const sessionPct = limit.currentSession?.percentage;
1204
1231
  const weeklyPct = limit.allModels?.percentage;
1205
- const sessionText = sessionPct === null || sessionPct === undefined ? `${lt('session', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('session', {}, { locale })} ${Math.floor(sessionPct)}%`;
1206
- const weeklyText = weeklyPct === null || weeklyPct === undefined ? `${lt('week', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('week', {}, { locale })} ${Math.floor(weeklyPct)}%`;
1207
- section += `${limit.limitName}: ${sessionText}, ${weeklyText}\n`;
1232
+ const windowTexts = [];
1233
+ if (sessionPct !== null && sessionPct !== undefined) windowTexts.push(`${lt('session', {}, { locale })} ${Math.floor(sessionPct)}%`);
1234
+ if (weeklyPct !== null && weeklyPct !== undefined) windowTexts.push(`${lt('week', {}, { locale })} ${Math.floor(weeklyPct)}%`);
1235
+ section += `${limit.limitName}: ${windowTexts.join(', ')}\n`;
1208
1236
  }
1209
1237
  }
1210
1238
 
@@ -1409,8 +1437,8 @@ export async function getCachedDiskInfo(verbose = false) {
1409
1437
  }
1410
1438
 
1411
1439
  export async function getAllCachedLimits(verbose = false) {
1412
- const [claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose), getCachedClaudeSubscription(verbose), getCachedCodexSubscription(verbose)]);
1413
- return { claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription };
1440
+ const [claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription, telegram] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose), getCachedClaudeSubscription(verbose), getCachedCodexSubscription(verbose), getTelegramRateLimits(verbose)]);
1441
+ return { claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription, telegram };
1414
1442
  }
1415
1443
 
1416
1444
  export default {
@@ -334,6 +334,20 @@ en
334
334
  label "Subscription ends {{time}}"
335
335
  in "Subscription ends in {{duration}} ({{time}})"
336
336
  status "Subscription: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (local rolling telemetry)"
339
+ global
340
+ window "{{used}}/{{limit}} messages in 1s"
341
+ group
342
+ window "{{used}}/{{limit}} messages in busiest group over 1m"
343
+ last
344
+ rate
345
+ limit "Last 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "429 responses since startup: {{count}}"
349
+ retry
350
+ in "retry in {{seconds}}s"
337
351
  trial
338
352
  ends
339
353
  label "Trial ends {{time}}"
@@ -334,6 +334,20 @@ hi
334
334
  label "सदस्यता समाप्त होगी {{time}}"
335
335
  in "सदस्यता {{duration}} में समाप्त होगी ({{time}})"
336
336
  status "सदस्यता: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (स्थानीय रोलिंग टेलीमेट्री)"
339
+ global
340
+ window "1 सेकंड में {{used}}/{{limit}} संदेश"
341
+ group
342
+ window "सबसे व्यस्त समूह में 1 मिनट में {{used}}/{{limit}} संदेश"
343
+ last
344
+ rate
345
+ limit "अंतिम 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "स्टार्टअप के बाद 429 प्रतिक्रियाएँ: {{count}}"
349
+ retry
350
+ in "{{seconds}} सेकंड में पुनः प्रयास"
337
351
  trial
338
352
  ends
339
353
  label "ट्रायल समाप्त होगा {{time}}"
@@ -334,6 +334,20 @@ ru
334
334
  label "Подписка заканчивается {{time}}"
335
335
  in "Подписка заканчивается через {{duration}} ({{time}})"
336
336
  status "Подписка: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (локальная скользящая статистика)"
339
+ global
340
+ window "{{used}}/{{limit}} сообщений за 1 с"
341
+ group
342
+ window "{{used}}/{{limit}} сообщений в самой активной группе за 1 мин"
343
+ last
344
+ rate
345
+ limit "Последний ответ 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "Ответов 429 с момента запуска: {{count}}"
349
+ retry
350
+ in "повтор через {{seconds}} с"
337
351
  trial
338
352
  ends
339
353
  label "Пробный период заканчивается {{time}}"
@@ -334,6 +334,20 @@ zh
334
334
  label "订阅结束于 {{time}}"
335
335
  in "订阅将在 {{duration}} 后结束 ({{time}})"
336
336
  status "订阅: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API(本地滚动遥测)"
339
+ global
340
+ window "1 秒内 {{used}}/{{limit}} 条消息"
341
+ group
342
+ window "最繁忙群组 1 分钟内 {{used}}/{{limit}} 条消息"
343
+ last
344
+ rate
345
+ limit "最近一次 429:{{method}}"
346
+ rate
347
+ limit
348
+ responses "启动后的 429 响应:{{count}}"
349
+ retry
350
+ in "{{seconds}} 秒后重试"
337
351
  trial
338
352
  ends
339
353
  label "试用结束于 {{time}}"
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+ import { clampEnvValue, parseIntegerEnv, parseNumberEnv } from './env-config.lib.mjs';
3
4
 
4
5
  /**
5
6
  * Queue Configuration Module
@@ -178,19 +179,8 @@ export function parseQueueConfig(linoConfig) {
178
179
  }
179
180
  }
180
181
 
181
- // Helper function to safely parse floats with fallback
182
- const parseFloatWithDefault = (envVar, defaultValue) => {
183
- const value = getenv(envVar, defaultValue.toString());
184
- const parsed = parseFloat(value);
185
- return isNaN(parsed) ? defaultValue : parsed;
186
- };
187
-
188
- // Helper function to safely parse integers with fallback
189
- const parseIntWithDefault = (envVar, defaultValue) => {
190
- const value = getenv(envVar, defaultValue.toString());
191
- const parsed = parseInt(value);
192
- return isNaN(parsed) ? defaultValue : parsed;
193
- };
182
+ const parseFloatWithDefault = (envVar, defaultValue) => parseNumberEnv(envVar, defaultValue, { scope: 'queue-config' });
183
+ const parseIntWithDefault = (envVar, defaultValue) => parseIntegerEnv(envVar, defaultValue, { scope: 'queue-config' });
194
184
 
195
185
  const DEFAULT_MINIMUM_START_INTERVAL_MS = 10 * 60 * 1000;
196
186
  const minimumStartIntervalMs = parseIntWithDefault('HIVE_MIND_MIN_START_INTERVAL_FLOOR_MS', DEFAULT_MINIMUM_START_INTERVAL_MS);
@@ -279,7 +269,11 @@ export const QUEUE_CONFIG = {
279
269
  // can kill the next batch before host metrics have time to settle.
280
270
  // Issue #2053: operators can explicitly lower the safety floor on hosts where
281
271
  // resource metrics settle sooner. The default remains 10 minutes.
282
- MIN_START_INTERVAL_MS: Math.max(parseIntWithDefault('HIVE_MIND_MIN_START_INTERVAL_MS', DEFAULT_MINIMUM_START_INTERVAL_MS), minimumStartIntervalMs),
272
+ MIN_START_INTERVAL_MS: clampEnvValue('HIVE_MIND_MIN_START_INTERVAL_MS', parseIntWithDefault('HIVE_MIND_MIN_START_INTERVAL_MS', DEFAULT_MINIMUM_START_INTERVAL_MS), {
273
+ minimum: minimumStartIntervalMs,
274
+ scope: 'queue-config',
275
+ hint: 'Set HIVE_MIND_MIN_START_INTERVAL_FLOOR_MS to lower the minimum.',
276
+ }),
283
277
  CONSUMER_POLL_INTERVAL_MS: parseIntWithDefault('HIVE_MIND_CONSUMER_POLL_INTERVAL_MS', 60000), // 1 minute between queue checks
284
278
  MESSAGE_UPDATE_INTERVAL_MS: parseIntWithDefault('HIVE_MIND_MESSAGE_UPDATE_INTERVAL_MS', 60000), // 1 minute between status message updates
285
279
 
@@ -329,6 +329,7 @@ const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMess
329
329
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
330
330
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
331
331
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
332
+ const { installTelegramRateLimitTracker } = await import('./telegram-rate-limit.lib.mjs');
332
333
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
333
334
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
334
335
  const { trackSession, untrackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
@@ -352,12 +353,11 @@ await preloadAllLocales();
352
353
 
353
354
  const telegrafModule = await use('telegraf');
354
355
  const { Telegraf } = telegrafModule;
355
-
356
356
  const bot = new Telegraf(BOT_TOKEN, {
357
357
  handlerTimeout: Infinity, // Remove default 90s timeout; command handlers like /solve spawn long-running processes
358
358
  });
359
359
  installTelegramFormattingFallback(bot.telegram, { verbose: VERBOSE });
360
-
360
+ installTelegramRateLimitTracker(bot.telegram, { verbose: VERBOSE });
361
361
  // Track bot startup time (Unix seconds to match Telegram's message.date format)
362
362
  const BOT_START_TIME = Math.floor(Date.now() / 1000);
363
363
 
@@ -635,7 +635,7 @@ bot.command('limits', async ctx => {
635
635
  const claudeSubscription = limits.claudeSubscription?.success ? limits.claudeSubscription.subscription : null;
636
636
  const codexSubscription = limits.codexSubscription?.success ? limits.codexSubscription.subscription : null;
637
637
  const codexSection = formatCodexLimitsSection(limits.codex.success ? limits.codex : null, codexError, { locale: userLocale, subscription: codexSubscription });
638
- const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale, subscription: claudeSubscription });
638
+ const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale, subscription: claudeSubscription, telegramRateLimit: limits.telegram.telegramRateLimit });
639
639
  await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown', fallbackLocale: userLocale, verbose: VERBOSE });
640
640
  });
641
641
  bot.command('version', async ctx => {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * In-process Telegram Bot API rate-limit telemetry.
3
+ *
4
+ * Telegram does not expose a quota endpoint. The Bot API documents approximate
5
+ * outbound-message limits and reports actual flood control as error 429 with a
6
+ * ResponseParameters.retry_after value. This tracker therefore combines local
7
+ * rolling counters with the last observed server-side throttle.
8
+ */
9
+
10
+ export const TELEGRAM_RATE_LIMITS = Object.freeze({
11
+ GLOBAL_MESSAGES_PER_SECOND: 30,
12
+ GROUP_MESSAGES_PER_MINUTE: 20,
13
+ });
14
+
15
+ const TRACKER_INSTALLED = Symbol.for('hiveMind.telegramRateLimitTrackerInstalled');
16
+
17
+ function isMessageMethod(method) {
18
+ const name = String(method || '');
19
+ if (/^sendChatAction$/i.test(name)) return false;
20
+ return /^(send|copyMessage$|forwardMessage$|editMessage)/i.test(name);
21
+ }
22
+
23
+ function toChatId(payload) {
24
+ const value = payload?.chat_id;
25
+ return value === null || value === undefined ? null : String(value);
26
+ }
27
+
28
+ function isGroupChatId(chatId) {
29
+ return typeof chatId === 'string' && chatId.startsWith('-');
30
+ }
31
+
32
+ function percentage(used, limit) {
33
+ return limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
34
+ }
35
+
36
+ function extractRateLimitError(error) {
37
+ const response = error?.response || error;
38
+ const code = response?.error_code ?? response?.status ?? error?.code;
39
+ const description = response?.description || error?.description || error?.message || '';
40
+ if (Number(code) !== 429 && !/\b429\b|too many requests/i.test(String(description))) return null;
41
+
42
+ const rawRetryAfter = response?.parameters?.retry_after ?? error?.parameters?.retry_after;
43
+ const retryAfterSeconds = Number(rawRetryAfter);
44
+ return {
45
+ retryAfterSeconds: Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds : null,
46
+ description: String(description),
47
+ };
48
+ }
49
+
50
+ export class TelegramRateLimitTracker {
51
+ constructor({ now = Date.now } = {}) {
52
+ this.now = now;
53
+ this.messageEvents = [];
54
+ this.totalApiRequests = 0;
55
+ this.rateLimitResponses = 0;
56
+ this.lastRateLimit = null;
57
+ }
58
+
59
+ prune(now = this.now()) {
60
+ const oldestRelevant = now - 60_000;
61
+ this.messageEvents = this.messageEvents.filter(event => event.at > oldestRelevant);
62
+ }
63
+
64
+ recordRequest(method, payload = {}) {
65
+ this.totalApiRequests++;
66
+ if (!isMessageMethod(method)) return;
67
+
68
+ const now = this.now();
69
+ const chatId = toChatId(payload);
70
+ this.messageEvents.push({ at: now, method: String(method), chatId, isGroup: isGroupChatId(chatId) });
71
+ this.prune(now);
72
+ }
73
+
74
+ recordError(error, method, payload = {}) {
75
+ const rateLimit = extractRateLimitError(error);
76
+ if (!rateLimit) return false;
77
+
78
+ const observedAt = this.now();
79
+ this.rateLimitResponses++;
80
+ this.lastRateLimit = {
81
+ ...rateLimit,
82
+ method: String(method || 'unknown'),
83
+ chatId: toChatId(payload),
84
+ observedAt,
85
+ retryUntil: rateLimit.retryAfterSeconds === null ? null : observedAt + rateLimit.retryAfterSeconds * 1_000,
86
+ };
87
+ return true;
88
+ }
89
+
90
+ getSnapshot() {
91
+ const now = this.now();
92
+ this.prune(now);
93
+
94
+ const globalUsed = this.messageEvents.filter(event => event.at > now - 1_000).length;
95
+ const groupCounts = new Map();
96
+ for (const event of this.messageEvents) {
97
+ if (!event.isGroup || event.chatId === null) continue;
98
+ groupCounts.set(event.chatId, (groupCounts.get(event.chatId) || 0) + 1);
99
+ }
100
+ let busiestGroupChatId = null;
101
+ let busiestGroupUsed = 0;
102
+ for (const [chatId, count] of groupCounts) {
103
+ if (count > busiestGroupUsed) {
104
+ busiestGroupChatId = chatId;
105
+ busiestGroupUsed = count;
106
+ }
107
+ }
108
+
109
+ let lastRateLimit = null;
110
+ if (this.lastRateLimit) {
111
+ const retryRemainingSeconds = this.lastRateLimit.retryUntil === null ? null : Math.max(0, Math.ceil((this.lastRateLimit.retryUntil - now) / 1_000));
112
+ lastRateLimit = { ...this.lastRateLimit, retryRemainingSeconds };
113
+ }
114
+
115
+ return {
116
+ global: {
117
+ used: globalUsed,
118
+ limit: TELEGRAM_RATE_LIMITS.GLOBAL_MESSAGES_PER_SECOND,
119
+ usedPercentage: percentage(globalUsed, TELEGRAM_RATE_LIMITS.GLOBAL_MESSAGES_PER_SECOND),
120
+ },
121
+ busiestGroup: {
122
+ used: busiestGroupUsed,
123
+ limit: TELEGRAM_RATE_LIMITS.GROUP_MESSAGES_PER_MINUTE,
124
+ usedPercentage: percentage(busiestGroupUsed, TELEGRAM_RATE_LIMITS.GROUP_MESSAGES_PER_MINUTE),
125
+ chatId: busiestGroupChatId,
126
+ },
127
+ totalApiRequests: this.totalApiRequests,
128
+ rateLimitResponses: this.rateLimitResponses,
129
+ lastRateLimit,
130
+ };
131
+ }
132
+ }
133
+
134
+ const defaultTracker = new TelegramRateLimitTracker();
135
+
136
+ export function getTelegramRateLimits(verbose = false) {
137
+ const telegramRateLimit = defaultTracker.getSnapshot();
138
+ if (verbose) console.log('[VERBOSE] /limits Telegram Bot API telemetry:', JSON.stringify(telegramRateLimit, null, 2));
139
+ return { success: true, telegramRateLimit };
140
+ }
141
+
142
+ export function installTelegramRateLimitTracker(telegram, { tracker = defaultTracker, verbose = false } = {}) {
143
+ if (!telegram || telegram[TRACKER_INSTALLED]) return telegram;
144
+ const originalCallApi = telegram.callApi;
145
+ if (typeof originalCallApi !== 'function') return telegram;
146
+
147
+ telegram.callApi = async function trackedCallApi(method, payload = {}, ...rest) {
148
+ tracker.recordRequest(method, payload);
149
+ if (verbose && isMessageMethod(method)) {
150
+ const snapshot = tracker.getSnapshot();
151
+ console.log(`[VERBOSE] Telegram Bot API ${method}: global ${snapshot.global.used}/${snapshot.global.limit} messages/1s; busiest group ${snapshot.busiestGroup.used}/${snapshot.busiestGroup.limit} messages/1m`);
152
+ }
153
+ try {
154
+ return await originalCallApi.call(this, method, payload, ...rest);
155
+ } catch (error) {
156
+ if (tracker.recordError(error, method, payload)) {
157
+ const observed = tracker.getSnapshot().lastRateLimit;
158
+ console.warn(`[telegram-bot] Telegram Bot API rate limit: method=${observed.method} chat=${observed.chatId ?? 'unknown'} retry_after=${observed.retryAfterSeconds ?? 'unknown'}s`);
159
+ if (verbose) console.error('[VERBOSE] Telegram Bot API 429 response:', JSON.stringify(error?.response || { message: error?.message }, null, 2));
160
+ }
161
+ throw error;
162
+ }
163
+ };
164
+
165
+ telegram[TRACKER_INSTALLED] = true;
166
+ return telegram;
167
+ }