@link-assistant/hive-mind 2.6.1 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0f57fe3: Make the Telegram Bot API section of `/limits` report real usage instead of near-permanent zeroes. Every Bot API call is now counted (not just sends), each documented flood-control window is tracked separately, and the section shows a single bar for the window closest to refusing the next request. Limits are learned from Telegram's own answers: a success proves capacity, a 429 proves a ceiling, and an active `retry_after` shows a full bar with the countdown.
8
+
9
+ ## 2.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 0bac9fd: Add an issue-type-aware `--deep-analysis` option for every supported AI tool, with development-log collection gated by `--development-log`.
14
+
3
15
  ## 2.6.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -8,7 +8,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
10
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
11
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
11
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
12
12
 
13
13
  /**
14
14
  * Build the user prompt for Agent
@@ -67,9 +67,9 @@ export const buildUserPrompt = params => {
67
67
  promptLines.push('');
68
68
  }
69
69
 
70
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
71
- if (developmentLogPrompt) {
72
- promptLines.push(developmentLogPrompt, '');
70
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
71
+ if (issueResearchPrompt) {
72
+ promptLines.push(issueResearchPrompt, '');
73
73
  }
74
74
 
75
75
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool: 'agent', argv });
@@ -10,7 +10,7 @@ import { primaryModelNames } from './models/index.mjs';
10
10
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
11
11
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
12
12
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
13
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
13
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
14
14
 
15
15
  /**
16
16
  * Build the user prompt for Claude
@@ -80,9 +80,9 @@ export const buildUserPrompt = params => {
80
80
  promptLines.push('');
81
81
  }
82
82
 
83
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
84
- if (developmentLogPrompt) {
85
- promptLines.push(developmentLogPrompt, '');
83
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
84
+ if (issueResearchPrompt) {
85
+ promptLines.push(issueResearchPrompt, '');
86
86
  }
87
87
 
88
88
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool: 'claude', argv, claudeVersion });
@@ -9,7 +9,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
9
9
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
10
10
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
11
11
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
12
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
12
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
13
13
 
14
14
  /**
15
15
  * Build the user prompt for Codex
@@ -68,9 +68,9 @@ export const buildUserPrompt = params => {
68
68
  promptLines.push('');
69
69
  }
70
70
 
71
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
72
- if (developmentLogPrompt) {
73
- promptLines.push(developmentLogPrompt, '');
71
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
72
+ if (issueResearchPrompt) {
73
+ promptLines.push(issueResearchPrompt, '');
74
74
  }
75
75
 
76
76
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool: 'codex', argv });
@@ -0,0 +1,35 @@
1
+ import { buildDevelopmentLogDirectory, buildDevelopmentLogPrompt, isBugIssueType, isDevelopmentLogEnabled } from './development-log.lib.mjs';
2
+
3
+ export const isDeepAnalysisEnabled = argv => argv?.deepAnalysis === true || argv?.['deep-analysis'] === true;
4
+
5
+ export const buildDeepAnalysisPrompt = ({ argv, issueNumber, prNumber, issueType }) => {
6
+ if (!isDeepAnalysisEnabled(argv)) return '';
7
+
8
+ const resolvedIssueType = issueType ?? argv?.issueType ?? null;
9
+ const isBug = isBugIssueType(resolvedIssueType);
10
+ const lines = [];
11
+
12
+ // Issue #1596 owns development-log collection. Deep analysis may refer to
13
+ // that directory, but must never activate logging by itself.
14
+ if (isDevelopmentLogEnabled(argv)) {
15
+ const directory = buildDevelopmentLogDirectory({ issueNumber, prNumber });
16
+ lines.push(isBug ? `Download all logs and collect data related about the issue to this repository, and compile that data into the ${directory} folder.` : `Collect data related about the issue to this repository, and compile that data into the ${directory} folder.`);
17
+ }
18
+
19
+ if (isBug) {
20
+ lines.push('Use the collected evidence to do a deep analysis (search online for additional facts and data), reconstruct the timeline/sequence of events, list each and every requirement from the issue, find the root cause of each problem, and propose possible solutions and solution plans for each requirement. Also check online for known existing components/libraries that solve a similar problem or can help.', 'If there is not enough data to find the actual root cause, add debug output and a verbose mode (if not already present) so the root cause can be found on the next iteration. Keep the default state switched off.', 'If the issue is related to another repository/project, report issues on GitHub for that project when possible. Each report must contain reproducible examples, workarounds, and suggestions for fixing the issue in code.');
21
+ } else {
22
+ lines.push('Do a deep analysis (search online for additional facts and data), list each and every requirement from the issue, and propose possible solutions and solution plans for each requirement. Also check online for known existing components/libraries that solve a similar problem or can help.');
23
+ }
24
+
25
+ lines.push('Double-check that the requirements are fully applied to the entire codebase: if an issue exists in multiple places, apply it in all of them.');
26
+ return `\n${lines.join('\n\n')}\n`;
27
+ };
28
+
29
+ // Development-log and deep-analysis instructions occupy the same position in
30
+ // the initial user prompt. Selecting deep analysis subsumes the shorter
31
+ // development-log sentence so it is not duplicated when both flags are used.
32
+ export const buildIssueResearchPrompt = params => {
33
+ const deepAnalysisPrompt = buildDeepAnalysisPrompt(params);
34
+ return deepAnalysisPrompt || buildDevelopmentLogPrompt(params);
35
+ };
@@ -40,6 +40,8 @@ export const isBugIssueType = issueType => {
40
40
  // (yargs exposes both the camelCase and kebab-case keys).
41
41
  export const isDevelopmentLogEnabled = argv => argv?.developmentLog === true || argv?.['development-log'] === true;
42
42
 
43
+ export const isIssueTypeAwarePromptEnabled = argv => isDevelopmentLogEnabled(argv) || argv?.deepAnalysis === true || argv?.['deep-analysis'] === true;
44
+
43
45
  export const buildDevelopmentLogPrompt = ({ argv, issueNumber, prNumber, issueType }) => {
44
46
  if (!(argv?.developmentLog || argv?.['development-log'])) return '';
45
47
 
@@ -8,7 +8,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
10
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
11
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
11
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
12
12
 
13
13
  /**
14
14
  * Build the user prompt for Gemini
@@ -58,9 +58,9 @@ export const buildUserPrompt = params => {
58
58
  promptLines.push('');
59
59
  }
60
60
 
61
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
62
- if (developmentLogPrompt) {
63
- promptLines.push(developmentLogPrompt, '');
61
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
62
+ if (issueResearchPrompt) {
63
+ promptLines.push(issueResearchPrompt, '');
64
64
  }
65
65
 
66
66
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool: 'gemini', argv });
@@ -85,12 +85,18 @@ 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',
88
+ telegram_api: 'Telegram Bot API',
89
+ telegram_flood_control: 'flood control',
91
90
  telegram_last_rate_limit: 'Last 429: {{method}}',
91
+ telegram_observed_limit: 'observed limit',
92
+ telegram_peak: 'peak {{peak}}',
92
93
  telegram_rate_limit_responses: '429 responses since startup: {{count}}',
93
- telegram_retry_in: 'retry in {{seconds}}s',
94
+ telegram_requests: '{{used}}/{{limit}} requests',
95
+ telegram_retry_in: 'retry in {{duration}}',
96
+ telegram_scope_broadcast: 'messages to all chats, 1s',
97
+ telegram_scope_chat: 'messages per chat, 1m',
98
+ telegram_scope_group: 'messages per group, 1m',
99
+ telegram_scope_other: 'other API requests, 1s',
94
100
  trial_ends: 'Trial ends {{time}}',
95
101
  trial_ends_in: 'Trial ends in {{duration}} ({{time}})',
96
102
  unavailable: 'unavailable',
@@ -16,6 +16,9 @@ import { classifyCodexRateLimitWindows } from './codex-rate-limit-windows.lib.mj
16
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).
17
17
  import { formatLimitResetsAt, formatLimitResetsIn, formatLocalizedCurrentTime, formatLocalizedRelativeTime, formatLocalizedResetTime, localizeCompactDuration, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
18
18
  import { formatSubscriptionHeading, formatSubscriptionLines, getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo } from './limits-subscription.lib.mjs';
19
+ export { getProgressBar } from './progress-bar.lib.mjs';
20
+ import { getProgressBar } from './progress-bar.lib.mjs';
21
+ import { formatTelegramLimitsSection } from './telegram-limits-section.lib.mjs';
19
22
  import { getTelegramRateLimits } from './telegram-rate-limit.lib.mjs';
20
23
  export { getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo };
21
24
  // Initialize dayjs plugins
@@ -984,40 +987,6 @@ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CO
984
987
  }
985
988
  }
986
989
 
987
- /**
988
- * Generate a text-based progress bar for usage percentage
989
- * @param {number} percentage - Usage percentage (0-100)
990
- * @param {number|null} thresholdPercentage - Optional threshold position to show in the bar (0-100)
991
- * @returns {string} Text-based progress bar
992
- * @see https://github.com/link-assistant/hive-mind/issues/1242
993
- */
994
- export function getProgressBar(percentage, thresholdPercentage = null) {
995
- const totalBlocks = 30;
996
- const filledBlocks = Math.round((percentage / 100) * totalBlocks);
997
-
998
- if (thresholdPercentage === null) {
999
- // No threshold - original behavior
1000
- const emptyBlocks = totalBlocks - filledBlocks;
1001
- return '\u2593'.repeat(filledBlocks) + '\u2591'.repeat(emptyBlocks);
1002
- }
1003
-
1004
- // With threshold marker
1005
- const thresholdPos = Math.round((thresholdPercentage / 100) * totalBlocks);
1006
- let bar = '';
1007
-
1008
- for (let i = 0; i < totalBlocks; i++) {
1009
- if (i === thresholdPos) {
1010
- bar += '│'; // Threshold marker (U+2502 Box Drawings Light Vertical)
1011
- } else if (i < filledBlocks) {
1012
- bar += '▓'; // Filled (U+2593)
1013
- } else {
1014
- bar += '░'; // Empty (U+2591)
1015
- }
1016
- }
1017
-
1018
- return bar;
1019
- }
1020
-
1021
990
  /**
1022
991
  * Calculate the percentage of time that has passed in a period
1023
992
  * @param {string} resetsAt - ISO date string when the period resets
@@ -1125,23 +1094,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1125
1094
  sections.push(section);
1126
1095
  }
1127
1096
 
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
- }
1097
+ const telegramSection = formatTelegramLimitsSection(options?.telegramRateLimit, { locale });
1098
+ if (telegramSection) sections.push(telegramSection);
1145
1099
 
1146
1100
  const claudeHeading = formatSubscriptionHeading('claude', subscription, { locale });
1147
1101
  const useShortClaudeLabels = Boolean(claudeHeading);
@@ -335,19 +335,26 @@ en
335
335
  in "Subscription ends in {{duration}} ({{time}})"
336
336
  status "Subscription: {{status}}"
337
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"
338
+ api "Telegram Bot API"
339
+ flood
340
+ control "flood control"
343
341
  last
344
342
  rate
345
343
  limit "Last 429: {{method}}"
344
+ observed
345
+ limit "observed limit"
346
+ peak "peak {{peak}}"
346
347
  rate
347
348
  limit
348
349
  responses "429 responses since startup: {{count}}"
350
+ requests "{{used}}/{{limit}} requests"
349
351
  retry
350
- in "retry in {{seconds}}s"
352
+ in "retry in {{duration}}"
353
+ scope
354
+ broadcast "messages to all chats, 1s"
355
+ chat "messages per chat, 1m"
356
+ group "messages per group, 1m"
357
+ other "other API requests, 1s"
351
358
  trial
352
359
  ends
353
360
  label "Trial ends {{time}}"
@@ -335,19 +335,26 @@ hi
335
335
  in "सदस्यता {{duration}} में समाप्त होगी ({{time}})"
336
336
  status "सदस्यता: {{status}}"
337
337
  telegram
338
- api "Telegram Bot API (स्थानीय रोलिंग टेलीमेट्री)"
339
- global
340
- window "1 सेकंड में {{used}}/{{limit}} संदेश"
341
- group
342
- window "सबसे व्यस्त समूह में 1 मिनट में {{used}}/{{limit}} संदेश"
338
+ api "Telegram Bot API"
339
+ flood
340
+ control "फ्लड नियंत्रण"
343
341
  last
344
342
  rate
345
343
  limit "अंतिम 429: {{method}}"
344
+ observed
345
+ limit "मापी गई सीमा"
346
+ peak "शिखर {{peak}}"
346
347
  rate
347
348
  limit
348
349
  responses "स्टार्टअप के बाद 429 प्रतिक्रियाएँ: {{count}}"
350
+ requests "{{used}}/{{limit}} अनुरोध"
349
351
  retry
350
- in "{{seconds}} सेकंड में पुनः प्रयास"
352
+ in "{{duration}} में पुनः प्रयास"
353
+ scope
354
+ broadcast "सभी चैट में संदेश, 1 सेकंड"
355
+ chat "प्रति चैट संदेश, 1 मिनट"
356
+ group "प्रति समूह संदेश, 1 मिनट"
357
+ other "अन्य API अनुरोध, 1 सेकंड"
351
358
  trial
352
359
  ends
353
360
  label "ट्रायल समाप्त होगा {{time}}"
@@ -335,19 +335,26 @@ ru
335
335
  in "Подписка заканчивается через {{duration}} ({{time}})"
336
336
  status "Подписка: {{status}}"
337
337
  telegram
338
- api "Telegram Bot API (локальная скользящая статистика)"
339
- global
340
- window "{{used}}/{{limit}} сообщений за 1 с"
341
- group
342
- window "{{used}}/{{limit}} сообщений в самой активной группе за 1 мин"
338
+ api "Telegram Bot API"
339
+ flood
340
+ control "контроль флуда"
343
341
  last
344
342
  rate
345
343
  limit "Последний ответ 429: {{method}}"
344
+ observed
345
+ limit "измеренный лимит"
346
+ peak "пик {{peak}}"
346
347
  rate
347
348
  limit
348
349
  responses "Ответов 429 с момента запуска: {{count}}"
350
+ requests "{{used}}/{{limit}} запросов"
349
351
  retry
350
- in "повтор через {{seconds}} с"
352
+ in "повтор через {{duration}}"
353
+ scope
354
+ broadcast "сообщения во все чаты, 1 с"
355
+ chat "сообщения в чат, 1 мин"
356
+ group "сообщения в группу, 1 мин"
357
+ other "другие запросы API, 1 с"
351
358
  trial
352
359
  ends
353
360
  label "Пробный период заканчивается {{time}}"
@@ -335,19 +335,26 @@ zh
335
335
  in "订阅将在 {{duration}} 后结束 ({{time}})"
336
336
  status "订阅: {{status}}"
337
337
  telegram
338
- api "Telegram Bot API(本地滚动遥测)"
339
- global
340
- window "1 秒内 {{used}}/{{limit}} 条消息"
341
- group
342
- window "最繁忙群组 1 分钟内 {{used}}/{{limit}} 条消息"
338
+ api "Telegram Bot API"
339
+ flood
340
+ control "限流"
343
341
  last
344
342
  rate
345
343
  limit "最近一次 429:{{method}}"
344
+ observed
345
+ limit "实测上限"
346
+ peak "峰值 {{peak}}"
346
347
  rate
347
348
  limit
348
349
  responses "启动后的 429 响应:{{count}}"
350
+ requests "{{used}}/{{limit}} 个请求"
349
351
  retry
350
- in "{{seconds}} 秒后重试"
352
+ in "{{duration}} 后重试"
353
+ scope
354
+ broadcast "发送到所有聊天,1 秒"
355
+ chat "每个聊天的消息,1 分钟"
356
+ group "每个群组的消息,1 分钟"
357
+ other "其他 API 请求,1 秒"
351
358
  trial
352
359
  ends
353
360
  label "试用结束于 {{time}}"
@@ -8,7 +8,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
10
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
11
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
11
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
12
12
 
13
13
  /**
14
14
  * Build the user prompt for OpenCode
@@ -67,9 +67,9 @@ export const buildUserPrompt = params => {
67
67
  promptLines.push('');
68
68
  }
69
69
 
70
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
71
- if (developmentLogPrompt) {
72
- promptLines.push(developmentLogPrompt, '');
70
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
71
+ if (issueResearchPrompt) {
72
+ promptLines.push(issueResearchPrompt, '');
73
73
  }
74
74
 
75
75
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool: 'opencode', argv });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Text progress bar rendering shared by the /limits sections.
3
+ *
4
+ * Extracted from limits.lib.mjs so that section formatters can render bars
5
+ * without importing limits.lib.mjs itself (which would be a cycle, since
6
+ * limits.lib.mjs imports those formatters).
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1242
9
+ */
10
+
11
+ /**
12
+ * Generate a text-based progress bar for usage percentage
13
+ * @param {number} percentage - Usage percentage (0-100)
14
+ * @param {number|null} thresholdPercentage - Optional threshold position to show in the bar (0-100)
15
+ * @returns {string} Text-based progress bar
16
+ * @see https://github.com/link-assistant/hive-mind/issues/1242
17
+ */
18
+ export function getProgressBar(percentage, thresholdPercentage = null) {
19
+ const totalBlocks = 30;
20
+ const filledBlocks = Math.round((percentage / 100) * totalBlocks);
21
+
22
+ if (thresholdPercentage === null) {
23
+ // No threshold - original behavior
24
+ const emptyBlocks = totalBlocks - filledBlocks;
25
+ return '▓'.repeat(filledBlocks) + '░'.repeat(emptyBlocks);
26
+ }
27
+
28
+ // With threshold marker
29
+ const thresholdPos = Math.round((thresholdPercentage / 100) * totalBlocks);
30
+ let bar = '';
31
+
32
+ for (let i = 0; i < totalBlocks; i++) {
33
+ if (i === thresholdPos) {
34
+ bar += '│'; // Threshold marker (U+2502 Box Drawings Light Vertical)
35
+ } else if (i < filledBlocks) {
36
+ bar += '▓'; // Filled (U+2593)
37
+ } else {
38
+ bar += '░'; // Empty (U+2591)
39
+ }
40
+ }
41
+
42
+ return bar;
43
+ }
44
+
45
+ export default { getProgressBar };
@@ -8,7 +8,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
10
  import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
11
- import { buildDevelopmentLogPrompt } from './development-log.lib.mjs';
11
+ import { buildIssueResearchPrompt } from './deep-analysis.lib.mjs';
12
12
 
13
13
  /**
14
14
  * Build the user prompt for Qwen Code
@@ -58,9 +58,9 @@ export const buildUserPrompt = params => {
58
58
  promptLines.push('');
59
59
  }
60
60
 
61
- const developmentLogPrompt = buildDevelopmentLogPrompt({ argv, issueNumber, prNumber }).trim();
62
- if (developmentLogPrompt) {
63
- promptLines.push(developmentLogPrompt, '');
61
+ const issueResearchPrompt = buildIssueResearchPrompt({ argv, issueNumber, prNumber }).trim();
62
+ if (issueResearchPrompt) {
63
+ promptLines.push(issueResearchPrompt, '');
64
64
  }
65
65
 
66
66
  const thinkingPromptInstruction = getThinkingPromptInstruction({ tool, argv });
@@ -518,6 +518,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
518
518
  description: 'Prompt for issue-data collection under ./dev/log/issues/{issue-id}/pulls/{pull-id}, preserve native tool state under sessions/{UUID}, and commit the artifacts when solve finishes. Supported for --tool claude, --tool codex, --tool opencode, --tool agent, --tool qwen, and --tool gemini.',
519
519
  default: false,
520
520
  },
521
+ 'deep-analysis': {
522
+ type: 'boolean',
523
+ description: 'Prompt for issue-type-aware deep analysis, online research, complete requirement coverage, and solution planning. Data collection under ./dev/log is included only when --development-log is also enabled. Supported for --tool claude, --tool codex, --tool opencode, --tool agent, --tool qwen, and --tool gemini.',
524
+ default: false,
525
+ },
521
526
  'use-handoff': {
522
527
  type: 'boolean',
523
528
  description: '[EXPERIMENTAL] Enable the HANDOFF.md continuity Agent Skill so a session can continue the work of a previous session — even when a different AI tool is used (e.g. Claude and Codex continuing each other in the same pull request). A real SKILL.md (the open Agent Skills standard) is deployed into the working directory so each tool loads it natively (.claude/skills/handoff/ for Claude, .agents/skills/handoff/ for Codex). The AI reads HANDOFF.md (repository root) first when present and keeps it updated with task, current state, decisions, next steps, gotchas, and critical files. HANDOFF.md is committed to the PR branch so it persists across the ephemeral per-session working directories; the SKILL.md itself is re-deployed each session and git-excluded so it never pollutes the PR. The same skill file is used identically for --tool claude and --tool codex. Disabled by default (issue #1877).',
package/src/solve.mjs CHANGED
@@ -62,7 +62,7 @@ const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.dis
62
62
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
63
63
  const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
64
64
  const { attachFinalLogIfMissing } = await import('./attach-logs-guarantee.lib.mjs'); // Issue #1952
65
- const { collectAndCommitDevelopmentLogArtifacts, fetchIssueType, isDevelopmentLogEnabled } = await import('./development-log.lib.mjs');
65
+ const { collectAndCommitDevelopmentLogArtifacts, fetchIssueType, isDevelopmentLogEnabled, isIssueTypeAwarePromptEnabled } = await import('./development-log.lib.mjs');
66
66
  const { createDevelopmentLogFinalizer } = await import('./development-log.finalize.lib.mjs');
67
67
  // Issue #1625: centralized markers + tracked comment posting for solve.mjs's
68
68
  // own usage-limit notifications (so they're excluded from the
@@ -486,8 +486,8 @@ if (isPrUrl) {
486
486
  }
487
487
  // Issues #1212, #1462: Store issueNumber globally for error handlers (attach failure logs to issue when no PR exists)
488
488
  global.issueNumber = issueNumber;
489
- // Issue #1596: detect the issue type so the development-log prompt automatically uses bug vs feature/task wording.
490
- if (isDevelopmentLogEnabled(argv) && issueNumber) argv.issueType = await fetchIssueType({ owner, repo, issueNumber, $, log });
489
+ // Issues #1595 and #1596: detect the issue type so analysis and logging prompts use bug vs feature/task wording.
490
+ if (isIssueTypeAwarePromptEnabled(argv) && issueNumber) argv.issueType = await fetchIssueType({ owner, repo, issueNumber, $, log });
491
491
  const workspaceInfo = argv.enableWorkspaces ? { owner, repo, issueNumber } : null;
492
492
  const { tempDir, workspaceTmpDir, needsClone } = await setupTempDirectory(argv, workspaceInfo);
493
493
  cleanupContext.tempDir = tempDir;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Renders the Telegram Bot API section of `/limits` (issue #2070).
3
+ *
4
+ * The section shows exactly one bar, for the window closest to refusing the next
5
+ * request, because Telegram enforces several limits at once and only the tightest
6
+ * one can stop the bot. See telegram-rate-limit.lib.mjs for how the windows are
7
+ * measured and corrected.
8
+ */
9
+
10
+ import { localizeCompactDuration, lt } from './limits-i18n.lib.mjs';
11
+ import { getProgressBar } from './progress-bar.lib.mjs';
12
+
13
+ const FULL_PERCENTAGE = 100;
14
+
15
+ /**
16
+ * Format a `retry_after` countdown.
17
+ * Telegram's values span three orders of magnitude — routine flood control returns
18
+ * ~10s, while repeat offences have been observed returning 2282s and above — so
19
+ * the countdown has to carry hours, not just seconds.
20
+ * @see https://github.com/tdlib/telegram-bot-api/issues/184
21
+ */
22
+ export function formatRetryDuration(seconds, options = {}) {
23
+ const total = Math.max(0, Math.round(Number(seconds) || 0));
24
+ const parts = [];
25
+ const hours = Math.floor(total / 3600);
26
+ const minutes = Math.floor((total % 3600) / 60);
27
+ const remainingSeconds = total % 60;
28
+ if (hours > 0) parts.push(`${hours}h`);
29
+ if (minutes > 0) parts.push(`${minutes}m`);
30
+ if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
31
+ return localizeCompactDuration(parts.join(' '), options);
32
+ }
33
+
34
+ function formatThrottledLabel(lastRateLimit, options) {
35
+ const label = lt('telegram_flood_control', {}, options);
36
+ const seconds = lastRateLimit?.retryRemainingSeconds;
37
+ if (!seconds) return label;
38
+ return `${label}, ${lt('telegram_retry_in', { duration: formatRetryDuration(seconds, options) }, options)}`;
39
+ }
40
+
41
+ function formatUsageLine(display, options) {
42
+ const requests = lt('telegram_requests', { used: display.used, limit: display.limit }, options);
43
+ // Say so when the number came from Telegram refusing or accepting a call,
44
+ // rather than from the documentation, since the two disagree in practice.
45
+ const source = display.limitSource === 'observed' ? ` (${lt('telegram_observed_limit', {}, options)})` : '';
46
+ return `${requests}${source}, ${lt('telegram_peak', { peak: display.peak }, options)}`;
47
+ }
48
+
49
+ /**
50
+ * @param {object|null} telegramRateLimit - Snapshot from TelegramRateLimitTracker
51
+ * @param {object} options - { locale }
52
+ * @returns {string|null} Section text, or null when there is nothing measured yet
53
+ */
54
+ export function formatTelegramLimitsSection(telegramRateLimit, options = {}) {
55
+ const display = telegramRateLimit?.display;
56
+ if (!display) return null;
57
+
58
+ const locale = options?.locale || null;
59
+ const lastRateLimit = telegramRateLimit.lastRateLimit || null;
60
+ // While retry_after is still counting down, the rolling windows are the wrong
61
+ // thing to report: Telegram has already said no, whatever they estimate. Show
62
+ // the refusal, and let the bar fall back to measured usage once it expires.
63
+ const throttled = Boolean(telegramRateLimit.throttled);
64
+ const usedPercentage = throttled ? FULL_PERCENTAGE : display.usedPercentage;
65
+ const label = throttled ? formatThrottledLabel(lastRateLimit, { locale }) : lt(`telegram_scope_${display.id}`, {}, { locale });
66
+ const suffix = usedPercentage >= FULL_PERCENTAGE ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
67
+
68
+ let section = `${lt('telegram_api', {}, { locale })}\n`;
69
+ section += `${getProgressBar(usedPercentage)} ${usedPercentage}%${suffix} (${label})\n`;
70
+ section += `${formatUsageLine(display, { locale })}\n`;
71
+ section += `${lt('telegram_rate_limit_responses', { count: telegramRateLimit.rateLimitResponses }, { locale })}\n`;
72
+ if (lastRateLimit) section += `${lt('telegram_last_rate_limit', { method: lastRateLimit.method }, { locale })}\n`;
73
+ return section;
74
+ }
75
+
76
+ export default { formatRetryDuration, formatTelegramLimitsSection };
@@ -1,24 +1,59 @@
1
1
  /**
2
- * In-process Telegram Bot API rate-limit telemetry.
2
+ * Telegram Bot API rate-limit telemetry for `/limits` (issues #2060 and #2070).
3
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.
4
+ * Telegram exposes no quota endpoint, and the official open-source Bot API
5
+ * server does not even implement the sending limits: `Client::get_retry_after_time`
6
+ * only parses `"Too Many Requests: retry after "` out of errors relayed from
7
+ * Telegram's closed backend. The published numbers are hedged ("about 30",
8
+ * "~30 users per second", "we may allow short bursts that go over this limit"),
9
+ * and Telegram tells bot authors to write clients that do not "depend on
10
+ * hardcoded limit values".
11
+ *
12
+ * This module therefore does not pretend to mirror a server counter. It keeps
13
+ * rolling windows modelled on the documented limits and then corrects each
14
+ * window's limit from what Telegram actually does: a successful call proves the
15
+ * window it landed in is allowed, and a 429 proves the window it landed in is
16
+ * not. The documented values are only a starting point.
17
+ *
18
+ * References:
19
+ * - https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this
20
+ * - https://core.telegram.org/bots/api#responseparameters
21
+ * - https://core.telegram.org/bots/features#dedicated-test-environment
22
+ * - https://github.com/tdlib/telegram-bot-api/blob/master/telegram-bot-api/Client.cpp
23
+ * - https://github.com/tdlib/td/issues/3034 (message edits share the sending limits)
24
+ * - https://grammy.dev/advanced/flood
25
+ */
26
+
27
+ const SECOND_MS = 1_000;
28
+ const MINUTE_MS = 60_000;
29
+
30
+ /**
31
+ * The windows Telegram is believed to enforce, with the evidence for each size.
32
+ *
33
+ * `chat` restates the documented "avoid sending more than one message per
34
+ * second" advisory as a sustained rate over a minute, because the same FAQ
35
+ * allows short bursts above it. grammY models that advisory the same way, as a
36
+ * sustained rate rather than a hard per-second cap.
37
+ *
38
+ * `other` has no documented size: Telegram states no limit for `getUpdates`,
39
+ * `getMe` or `answerCallbackQuery`, yet grammY lists "getUpdates cannot receive
40
+ * flood wait errors" among the false assumptions. It stays unknown, and hidden,
41
+ * until a 429 measures it.
8
42
  */
43
+ export const TELEGRAM_LIMIT_RULES = Object.freeze([Object.freeze({ id: 'chat', kind: 'message', scope: 'chat', windowMs: MINUTE_MS, documentedLimit: 60 }), Object.freeze({ id: 'group', kind: 'message', scope: 'group', windowMs: MINUTE_MS, documentedLimit: 20 }), Object.freeze({ id: 'broadcast', kind: 'message', scope: 'global', windowMs: SECOND_MS, documentedLimit: 30 }), Object.freeze({ id: 'other', kind: 'other', scope: 'global', windowMs: SECOND_MS, documentedLimit: null })]);
9
44
 
10
- export const TELEGRAM_RATE_LIMITS = Object.freeze({
11
- GLOBAL_MESSAGES_PER_SECOND: 30,
12
- GROUP_MESSAGES_PER_MINUTE: 20,
13
- });
45
+ /** A window must be at least this full before a 429 can be blamed on it. */
46
+ const BLAME_UTILIZATION = 0.5;
14
47
 
15
48
  const TRACKER_INSTALLED = Symbol.for('hiveMind.telegramRateLimitTrackerInstalled');
49
+ const MAX_WINDOW_MS = Math.max(...TELEGRAM_LIMIT_RULES.map(rule => rule.windowMs));
16
50
 
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
- }
51
+ /**
52
+ * Chat-scoped methods that read or signal instead of producing a message.
53
+ * telegraf-throttler excludes exactly these from the per-group sending limit.
54
+ * https://github.com/KnightNiwrem/telegraf-throttler
55
+ */
56
+ const NON_MESSAGE_CHAT_METHODS = new Set(['sendchataction', 'getchat', 'getchatadministrators', 'getchatmember', 'getchatmembercount', 'getchatmemberscount']);
22
57
 
23
58
  function toChatId(payload) {
24
59
  const value = payload?.chat_id;
@@ -33,6 +68,32 @@ function percentage(used, limit) {
33
68
  return limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
34
69
  }
35
70
 
71
+ /**
72
+ * Decide which limits a request is subject to.
73
+ *
74
+ * grammY, python-telegram-bot and telegraf-throttler all key on the presence of
75
+ * `chat_id` rather than on a method allow-list, because that parameter is what
76
+ * scopes a call to a conversation. Doing the same keeps message edits inside the
77
+ * sending limits, which TDLib's maintainer confirms they share.
78
+ */
79
+ export function classifyTelegramRequest(method, payload = {}) {
80
+ const name = String(method || '');
81
+ const chatId = toChatId(payload);
82
+ const kind = chatId !== null && !NON_MESSAGE_CHAT_METHODS.has(name.toLowerCase()) ? 'message' : 'other';
83
+ return { method: name, chatId, kind, isGroup: isGroupChatId(chatId) };
84
+ }
85
+
86
+ function ruleMatches(rule, event) {
87
+ if (rule.kind !== event.kind) return false;
88
+ if (rule.scope === 'group') return event.isGroup;
89
+ if (rule.scope === 'chat') return event.chatId !== null;
90
+ return true;
91
+ }
92
+
93
+ function ruleKey(rule, event) {
94
+ return rule.scope === 'global' ? '' : event.chatId;
95
+ }
96
+
36
97
  function extractRateLimitError(error) {
37
98
  const response = error?.response || error;
38
99
  const code = response?.error_code ?? response?.status ?? error?.code;
@@ -47,84 +108,202 @@ function extractRateLimitError(error) {
47
108
  };
48
109
  }
49
110
 
111
+ /** Order candidates so the window closest to refusing the next request wins. */
112
+ function isMoreConstrained(candidate, best) {
113
+ if (candidate.throttled !== best.throttled) return candidate.throttled;
114
+ if (candidate.remaining !== best.remaining) return candidate.remaining < best.remaining;
115
+ if (candidate.used !== best.used) return candidate.used > best.used;
116
+ return candidate.usedPercentage > best.usedPercentage;
117
+ }
118
+
119
+ function mostUtilized(candidates) {
120
+ return candidates.reduce((best, candidate) => (candidate.utilization > best.utilization ? candidate : best));
121
+ }
122
+
50
123
  export class TelegramRateLimitTracker {
51
124
  constructor({ now = Date.now } = {}) {
52
125
  this.now = now;
53
- this.messageEvents = [];
126
+ this.events = [];
54
127
  this.totalApiRequests = 0;
128
+ this.messageRequests = 0;
55
129
  this.rateLimitResponses = 0;
56
130
  this.lastRateLimit = null;
131
+ this.rules = new Map(
132
+ TELEGRAM_LIMIT_RULES.map(rule => [
133
+ rule.id,
134
+ {
135
+ rule,
136
+ limit: rule.documentedLimit,
137
+ limitSource: rule.documentedLimit === null ? 'unknown' : 'documented',
138
+ peak: 0,
139
+ throttledUntil: null,
140
+ },
141
+ ])
142
+ );
57
143
  }
58
144
 
59
145
  prune(now = this.now()) {
60
- const oldestRelevant = now - 60_000;
61
- this.messageEvents = this.messageEvents.filter(event => event.at > oldestRelevant);
146
+ const oldestRelevant = now - MAX_WINDOW_MS;
147
+ this.events = this.events.filter(event => event.at > oldestRelevant);
62
148
  }
63
149
 
150
+ /** Count matching requests inside one rule's window, split by chat when scoped. */
151
+ windowCounts(rule, now) {
152
+ const oldestRelevant = now - rule.windowMs;
153
+ const counts = new Map();
154
+ for (const event of this.events) {
155
+ if (event.at <= oldestRelevant || !ruleMatches(rule, event)) continue;
156
+ const key = ruleKey(rule, event);
157
+ counts.set(key, (counts.get(key) || 0) + 1);
158
+ }
159
+ return counts;
160
+ }
161
+
162
+ /**
163
+ * Record an outbound request and return the window counts it lands in, so the
164
+ * eventual response can be attributed to those exact windows.
165
+ */
64
166
  recordRequest(method, payload = {}) {
167
+ const at = this.now();
168
+ const event = { at, ...classifyTelegramRequest(method, payload) };
65
169
  this.totalApiRequests++;
66
- if (!isMessageMethod(method)) return;
170
+ if (event.kind === 'message') this.messageRequests++;
171
+ this.events.push(event);
172
+ this.prune(at);
67
173
 
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);
174
+ const counts = new Map();
175
+ for (const rule of TELEGRAM_LIMIT_RULES) {
176
+ if (!ruleMatches(rule, event)) continue;
177
+ counts.set(rule.id, this.windowCounts(rule, at).get(ruleKey(rule, event)) || 0);
178
+ }
179
+ return { event, counts };
180
+ }
181
+
182
+ /** Telegram accepted the request, so every window it landed in tolerates its count. */
183
+ recordSuccess(pending) {
184
+ if (!pending?.counts) return;
185
+ for (const [id, count] of pending.counts) {
186
+ const state = this.rules.get(id);
187
+ if (count > state.peak) state.peak = count;
188
+ // An unknown limit stays unknown: a success proves capacity, never a ceiling.
189
+ if (state.limit !== null && count > state.limit) {
190
+ state.limit = count;
191
+ state.limitSource = 'observed';
192
+ }
193
+ }
72
194
  }
73
195
 
74
- recordError(error, method, payload = {}) {
75
- const rateLimit = extractRateLimitError(error);
76
- if (!rateLimit) return false;
196
+ /**
197
+ * Pick the window that best explains a 429, or none when no modelled window
198
+ * was full enough to be a plausible cause.
199
+ */
200
+ blameRule(pending) {
201
+ if (!pending?.counts?.size) return null;
202
+ const candidates = [];
203
+ for (const [id, count] of pending.counts) {
204
+ const state = this.rules.get(id);
205
+ const utilization = state.limit === null ? Infinity : count / state.limit;
206
+ candidates.push({ state, count, utilization, explained: state.limit !== null && count >= state.limit });
207
+ }
77
208
 
78
- const observedAt = this.now();
209
+ const explained = candidates.filter(candidate => candidate.explained);
210
+ if (explained.length) return mostUtilized(explained);
211
+ // A 429 that arrives while every window is nearly empty was caused by state
212
+ // we cannot observe: flood control carries a penalty across windows and
213
+ // escalates on repeat offences. Blaming a window would collapse its limit
214
+ // for no reason, so learn nothing and only report the throttle.
215
+ const plausible = candidates.filter(candidate => candidate.count >= 2 && candidate.utilization >= BLAME_UTILIZATION);
216
+ return plausible.length ? mostUtilized(plausible) : null;
217
+ }
218
+
219
+ recordError(error, pending = null) {
220
+ const details = extractRateLimitError(error);
221
+ if (!details) return null;
222
+
223
+ const at = this.now();
79
224
  this.rateLimitResponses++;
225
+ const retryUntil = details.retryAfterSeconds === null ? null : at + details.retryAfterSeconds * SECOND_MS;
226
+ const blamed = this.blameRule(pending);
227
+ // Blame is decided from the count including this request, but the request
228
+ // itself was refused: it never landed in any window. Dropping it keeps the
229
+ // windows a record of what Telegram accepted, so `used` cannot exceed the
230
+ // ceiling the refusal just proved.
231
+ const landed = this.events.indexOf(pending?.event);
232
+ if (landed !== -1) this.events.splice(landed, 1);
233
+ if (blamed && !blamed.explained) {
234
+ // Telegram refused a window our estimate still considered allowed, so the
235
+ // estimate is too high: the real limit is below the refused count.
236
+ blamed.state.limit = Math.max(1, blamed.count - 1);
237
+ blamed.state.limitSource = 'observed';
238
+ }
239
+ if (blamed) blamed.state.throttledUntil = retryUntil;
240
+
80
241
  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,
242
+ method: pending?.event?.method || 'unknown',
243
+ chatId: pending?.event?.chatId ?? null,
244
+ retryAfterSeconds: details.retryAfterSeconds,
245
+ description: details.description,
246
+ observedAt: at,
247
+ retryUntil,
248
+ ruleId: blamed?.state.rule.id ?? null,
86
249
  };
87
- return true;
250
+ return this.lastRateLimit;
88
251
  }
89
252
 
90
- getSnapshot() {
91
- const now = this.now();
92
- this.prune(now);
253
+ describeRule(rule, now) {
254
+ const state = this.rules.get(rule.id);
255
+ if (state.limit === null) return null;
93
256
 
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);
257
+ let busiest = null;
258
+ for (const [key, count] of this.windowCounts(rule, now)) {
259
+ if (!busiest || count > busiest.count) busiest = { key, count };
99
260
  }
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
- }
261
+ // A per-chat window with no traffic has no subject, so showing it would add
262
+ // a phantom bar for a conversation the bot is not talking to.
263
+ if (!busiest) {
264
+ if (rule.scope !== 'global') return null;
265
+ busiest = { key: '', count: 0 };
107
266
  }
108
267
 
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 };
268
+ return {
269
+ id: rule.id,
270
+ scope: rule.scope,
271
+ windowMs: rule.windowMs,
272
+ chatId: rule.scope === 'global' ? null : busiest.key,
273
+ used: busiest.count,
274
+ limit: state.limit,
275
+ limitSource: state.limitSource,
276
+ peak: state.peak,
277
+ remaining: Math.max(0, state.limit - busiest.count),
278
+ usedPercentage: percentage(busiest.count, state.limit),
279
+ throttled: state.throttledUntil !== null && state.throttledUntil > now,
280
+ };
281
+ }
282
+
283
+ describeLastRateLimit(now) {
284
+ if (!this.lastRateLimit) return null;
285
+ const { retryUntil } = this.lastRateLimit;
286
+ const retryRemainingSeconds = retryUntil === null ? null : Math.max(0, Math.ceil((retryUntil - now) / SECOND_MS));
287
+ return { ...this.lastRateLimit, retryRemainingSeconds };
288
+ }
289
+
290
+ getSnapshot() {
291
+ const now = this.now();
292
+ this.prune(now);
293
+
294
+ const rules = TELEGRAM_LIMIT_RULES.map(rule => this.describeRule(rule, now)).filter(Boolean);
295
+ let display = null;
296
+ for (const candidate of rules) {
297
+ if (!display || isMoreConstrained(candidate, display)) display = candidate;
113
298
  }
114
299
 
300
+ const lastRateLimit = this.describeLastRateLimit(now);
115
301
  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
- },
302
+ display,
303
+ rules,
304
+ throttled: Boolean(lastRateLimit?.retryRemainingSeconds),
127
305
  totalApiRequests: this.totalApiRequests,
306
+ messageRequests: this.messageRequests,
128
307
  rateLimitResponses: this.rateLimitResponses,
129
308
  lastRateLimit,
130
309
  };
@@ -139,23 +318,27 @@ export function getTelegramRateLimits(verbose = false) {
139
318
  return { success: true, telegramRateLimit };
140
319
  }
141
320
 
321
+ /**
322
+ * Observe every Bot API call from Telegraf's single `callApi` choke point, which
323
+ * also carries `getUpdates` long polling, without delaying, retrying, reordering
324
+ * or swallowing anything.
325
+ */
142
326
  export function installTelegramRateLimitTracker(telegram, { tracker = defaultTracker, verbose = false } = {}) {
143
327
  if (!telegram || telegram[TRACKER_INSTALLED]) return telegram;
144
328
  const originalCallApi = telegram.callApi;
145
329
  if (typeof originalCallApi !== 'function') return telegram;
146
330
 
147
331
  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
- }
332
+ const pending = tracker.recordRequest(method, payload);
153
333
  try {
154
- return await originalCallApi.call(this, method, payload, ...rest);
334
+ const result = await originalCallApi.call(this, method, payload, ...rest);
335
+ tracker.recordSuccess(pending);
336
+ if (verbose) console.log(`[VERBOSE] Telegram Bot API ${method} accepted; windows: ${JSON.stringify(Object.fromEntries(pending.counts))}`);
337
+ return result;
155
338
  } 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`);
339
+ const observed = tracker.recordError(error, pending);
340
+ if (observed) {
341
+ console.warn(`[telegram-bot] Telegram Bot API rate limit: method=${observed.method} chat=${observed.chatId ?? 'unknown'} retry_after=${observed.retryAfterSeconds ?? 'unknown'}s window=${observed.ruleId ?? 'unattributed'}`);
159
342
  if (verbose) console.error('[VERBOSE] Telegram Bot API 429 response:', JSON.stringify(error?.response || { message: error?.message }, null, 2));
160
343
  }
161
344
  throw error;