@link-assistant/hive-mind 2.7.0 → 2.7.2
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 +12 -0
- package/package.json +1 -1
- package/src/auto-resume-uncommitted.lib.mjs +148 -0
- package/src/limits-i18n.lib.mjs +10 -4
- package/src/limits.lib.mjs +5 -51
- package/src/locales/en.lino +13 -6
- package/src/locales/hi.lino +13 -6
- package/src/locales/ru.lino +13 -6
- package/src/locales/zh.lino +13 -6
- package/src/option-suggestions.lib.mjs +2 -0
- package/src/progress-bar.lib.mjs +45 -0
- package/src/solve.config.lib.mjs +10 -0
- package/src/solve.mjs +2 -2
- package/src/solve.watch.lib.mjs +51 -6
- package/src/telegram-limits-section.lib.mjs +76 -0
- package/src/telegram-rate-limit.lib.mjs +254 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.7.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c6add61: Add experimental `--auto-resume-on-uncommitted-changes` flag (#1056) that complements the existing `--auto-restart-on-uncommitted-changes` by reusing the previous Claude Code session via `--resume <sessionId>` when uncommitted changes are detected, preserving the agent's accumulated context instead of starting a fresh session. The flag is disabled by default. A companion knob, `--auto-resume-on-uncommitted-changes-maximum-context-window-usage` (default 50%), bounds the worst-case peak usage of the usable pre-compaction context (respecting `--sub-session-size`); sessions at or above the threshold, or sessions whose usage cannot be verified, fall back to a fresh run.
|
|
8
|
+
|
|
9
|
+
## 2.7.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 2.7.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-resume on uncommitted changes — decision helpers.
|
|
5
|
+
*
|
|
6
|
+
* Issue #1056: when uncommitted changes are detected and the user has
|
|
7
|
+
* enabled `--auto-resume-on-uncommitted-changes`, we want to call the
|
|
8
|
+
* agent again with `--resume <sessionId>` (preserving context) instead
|
|
9
|
+
* of starting a fresh session — but only when the previous session has
|
|
10
|
+
* not already filled most of its usable pre-compaction context. The threshold
|
|
11
|
+
* defaults to 50% of that usable limit and is configurable via
|
|
12
|
+
* `--auto-resume-on-uncommitted-changes-maximum-context-window-usage`.
|
|
13
|
+
*
|
|
14
|
+
* This module is intentionally tool-agnostic. It does not perform the
|
|
15
|
+
* resume itself — it just decides whether resuming is viable and
|
|
16
|
+
* computes the percentage that the caller can log.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseSubSessionSize } from './sub-session-size.lib.mjs';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_MAX_CONTEXT_USAGE_PERCENT = 50;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the configured max-context-usage threshold (in percent) from argv.
|
|
25
|
+
*
|
|
26
|
+
* Accepts both the camelCase form populated by yargs
|
|
27
|
+
* (`autoResumeOnUncommittedChangesMaximumContextWindowUsage`) and the
|
|
28
|
+
* dash-cased flag itself, so that programmatic callers and CLI users
|
|
29
|
+
* see the same default.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} argv - parsed CLI arguments
|
|
32
|
+
* @returns {number} threshold in [0, 100]
|
|
33
|
+
*/
|
|
34
|
+
export const getAutoResumeMaxContextUsage = (argv = {}) => {
|
|
35
|
+
const candidates = [argv.autoResumeOnUncommittedChangesMaximumContextWindowUsage, argv['auto-resume-on-uncommitted-changes-maximum-context-window-usage']];
|
|
36
|
+
for (const value of candidates) {
|
|
37
|
+
if (value === undefined || value === null || value === '') continue;
|
|
38
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
39
|
+
if (Number.isFinite(parsed)) return Math.max(0, Math.min(100, parsed));
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_MAX_CONTEXT_USAGE_PERCENT;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether `--auto-resume-on-uncommitted-changes` is enabled.
|
|
46
|
+
* @param {Object} argv
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export const isAutoResumeOnUncommittedChangesEnabled = (argv = {}) => {
|
|
50
|
+
return argv.autoResumeOnUncommittedChanges === true || argv['auto-resume-on-uncommitted-changes'] === true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pick the largest peak-context-input across all models in a token-usage map
|
|
55
|
+
* that has a known model context limit, and return both the peak and the
|
|
56
|
+
* matching limit. We use the worst (highest-utilisation) model so that
|
|
57
|
+
* resuming with multi-model sessions does not silently exceed the threshold
|
|
58
|
+
* for the model that has the smallest remaining headroom.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object|null} tokenUsage - shape returned by calculateSessionTokens
|
|
61
|
+
* @returns {{peak: number, limit: number, contextLimit: number, ratio: number}|null} null when no model with verified usage and a known limit was found
|
|
62
|
+
*/
|
|
63
|
+
export const pickWorstContextUtilisation = (tokenUsage, argv = {}) => {
|
|
64
|
+
if (!tokenUsage || !tokenUsage.modelUsage) return null;
|
|
65
|
+
let worst = null;
|
|
66
|
+
for (const usage of Object.values(tokenUsage.modelUsage)) {
|
|
67
|
+
const contextLimit = usage?.modelInfo?.limit?.context;
|
|
68
|
+
if (!contextLimit || contextLimit <= 0) continue;
|
|
69
|
+
let limit = contextLimit;
|
|
70
|
+
try {
|
|
71
|
+
const configured = argv.subSessionSize ?? argv['sub-session-size'];
|
|
72
|
+
const subSession = parseSubSessionSize(configured, { contextWindow: contextLimit });
|
|
73
|
+
if (subSession.kind === 'tokens' && subSession.tokens > 0) {
|
|
74
|
+
limit = Math.min(contextLimit, subSession.tokens);
|
|
75
|
+
} else if (subSession.kind === 'percent' && subSession.tokens > 0) {
|
|
76
|
+
limit = Math.min(contextLimit, subSession.tokens);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Invalid values are reported by normal CLI validation. Programmatic
|
|
80
|
+
// callers still get a conservative decision against the model limit.
|
|
81
|
+
}
|
|
82
|
+
const peak = usage.peakContextUsage;
|
|
83
|
+
if (!Number.isFinite(peak) || peak <= 0) continue;
|
|
84
|
+
const ratio = peak / limit;
|
|
85
|
+
if (!worst || ratio > worst.ratio) worst = { peak, limit, contextLimit, ratio };
|
|
86
|
+
}
|
|
87
|
+
return worst;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether resuming is viable given a session ID and the previous
|
|
92
|
+
* session's token-usage data. Returns a structured result that the caller
|
|
93
|
+
* can log and act on.
|
|
94
|
+
*
|
|
95
|
+
* The decision tree is:
|
|
96
|
+
* - no auto-resume flag → 'disabled'
|
|
97
|
+
* - flag set, no session ID known → 'no_session_id'
|
|
98
|
+
* - flag set, session id known, no usable context-stat data → 'no_context_data'
|
|
99
|
+
* (fall back to a fresh run because available headroom cannot be verified)
|
|
100
|
+
* - flag set, peak >= threshold → 'context_too_full'
|
|
101
|
+
* - flag set, peak < threshold → 'ok'
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} params
|
|
104
|
+
* @param {Object} params.argv - parsed CLI arguments
|
|
105
|
+
* @param {string|null} params.sessionId - the session ID to resume, if any
|
|
106
|
+
* @param {Object|null} params.tokenUsage - result of calculateSessionTokens (may be null)
|
|
107
|
+
* @returns {{resume: boolean, reason: string, threshold: number, usedPercent: number|null, peak: number|null, limit: number|null}}
|
|
108
|
+
*/
|
|
109
|
+
export const decideAutoResumeOnUncommittedChanges = ({ argv = {}, sessionId = null, tokenUsage = null } = {}) => {
|
|
110
|
+
const threshold = getAutoResumeMaxContextUsage(argv);
|
|
111
|
+
if (!isAutoResumeOnUncommittedChangesEnabled(argv)) {
|
|
112
|
+
return { resume: false, reason: 'disabled', threshold, usedPercent: null, peak: null, limit: null };
|
|
113
|
+
}
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
return { resume: false, reason: 'no_session_id', threshold, usedPercent: null, peak: null, limit: null };
|
|
116
|
+
}
|
|
117
|
+
const worst = pickWorstContextUtilisation(tokenUsage, argv);
|
|
118
|
+
if (!worst) {
|
|
119
|
+
return { resume: false, reason: 'no_context_data', threshold, usedPercent: null, peak: null, limit: null };
|
|
120
|
+
}
|
|
121
|
+
const usedPercent = (worst.peak / worst.limit) * 100;
|
|
122
|
+
if (usedPercent >= threshold) {
|
|
123
|
+
return {
|
|
124
|
+
resume: false,
|
|
125
|
+
reason: 'context_too_full',
|
|
126
|
+
threshold,
|
|
127
|
+
usedPercent,
|
|
128
|
+
peak: worst.peak,
|
|
129
|
+
limit: worst.limit,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
resume: true,
|
|
134
|
+
reason: 'ok',
|
|
135
|
+
threshold,
|
|
136
|
+
usedPercent,
|
|
137
|
+
peak: worst.peak,
|
|
138
|
+
limit: worst.limit,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export default {
|
|
143
|
+
DEFAULT_MAX_CONTEXT_USAGE_PERCENT,
|
|
144
|
+
getAutoResumeMaxContextUsage,
|
|
145
|
+
isAutoResumeOnUncommittedChangesEnabled,
|
|
146
|
+
pickWorstContextUtilisation,
|
|
147
|
+
decideAutoResumeOnUncommittedChanges,
|
|
148
|
+
};
|
package/src/limits-i18n.lib.mjs
CHANGED
|
@@ -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
|
|
89
|
-
|
|
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
|
-
|
|
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',
|
package/src/limits.lib.mjs
CHANGED
|
@@ -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
|
|
1129
|
-
if (
|
|
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);
|
package/src/locales/en.lino
CHANGED
|
@@ -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
|
|
339
|
-
|
|
340
|
-
|
|
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 {{
|
|
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}}"
|
package/src/locales/hi.lino
CHANGED
|
@@ -335,19 +335,26 @@ hi
|
|
|
335
335
|
in "सदस्यता {{duration}} में समाप्त होगी ({{time}})"
|
|
336
336
|
status "सदस्यता: {{status}}"
|
|
337
337
|
telegram
|
|
338
|
-
api "Telegram Bot API
|
|
339
|
-
|
|
340
|
-
|
|
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 "{{
|
|
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}}"
|
package/src/locales/ru.lino
CHANGED
|
@@ -335,19 +335,26 @@ ru
|
|
|
335
335
|
in "Подписка заканчивается через {{duration}} ({{time}})"
|
|
336
336
|
status "Подписка: {{status}}"
|
|
337
337
|
telegram
|
|
338
|
-
api "Telegram Bot API
|
|
339
|
-
|
|
340
|
-
|
|
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 "повтор через {{
|
|
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}}"
|
package/src/locales/zh.lino
CHANGED
|
@@ -335,19 +335,26 @@ zh
|
|
|
335
335
|
in "订阅将在 {{duration}} 后结束 ({{time}})"
|
|
336
336
|
status "订阅: {{status}}"
|
|
337
337
|
telegram
|
|
338
|
-
api "Telegram Bot API
|
|
339
|
-
|
|
340
|
-
|
|
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 "{{
|
|
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}}"
|
|
@@ -197,6 +197,8 @@ const KNOWN_OPTION_NAMES = [
|
|
|
197
197
|
'auto-pull-request-creation',
|
|
198
198
|
'auto-commit-uncommitted-changes',
|
|
199
199
|
'auto-restart-on-uncommitted-changes',
|
|
200
|
+
'auto-resume-on-uncommitted-changes',
|
|
201
|
+
'auto-resume-on-uncommitted-changes-maximum-context-window-usage',
|
|
200
202
|
'continue-only-on-feedback',
|
|
201
203
|
'claude-file',
|
|
202
204
|
'gitkeep-file',
|
|
@@ -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 };
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -201,6 +201,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
201
201
|
description: 'Automatically restart when uncommitted changes are detected to allow the tool to handle them (default: true, use --no-auto-restart-on-uncommitted-changes to disable)',
|
|
202
202
|
default: true,
|
|
203
203
|
},
|
|
204
|
+
'auto-resume-on-uncommitted-changes': {
|
|
205
|
+
type: 'boolean',
|
|
206
|
+
description: 'EXPERIMENTAL: Automatically resume the previous Claude session when uncommitted changes are detected. Falls back to a fresh session when usable context headroom cannot be verified or is too low. Disabled by default; use --no-auto-resume-on-uncommitted-changes to switch it off explicitly.',
|
|
207
|
+
default: false,
|
|
208
|
+
},
|
|
209
|
+
'auto-resume-on-uncommitted-changes-maximum-context-window-usage': {
|
|
210
|
+
type: 'number',
|
|
211
|
+
description: 'Maximum usable pre-compaction context usage (percent) that still allows --auto-resume-on-uncommitted-changes to resume. The usable limit respects --sub-session-size. At or above this threshold the tool starts a fresh session (default: 50).',
|
|
212
|
+
default: 50,
|
|
213
|
+
},
|
|
204
214
|
'auto-restart-max-iterations': {
|
|
205
215
|
type: 'number',
|
|
206
216
|
description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
|
package/src/solve.mjs
CHANGED
|
@@ -12,7 +12,6 @@ const { configureGitHubRateLimitLogging, wrapDollarWithGhRetry } = await import(
|
|
|
12
12
|
const $ = wrapDollarWithGhRetry(__rawDollar$);
|
|
13
13
|
const config = await import('./solve.config.lib.mjs');
|
|
14
14
|
const { initializeConfig, parseArguments } = config;
|
|
15
|
-
// Import Sentry integration
|
|
16
15
|
const sentryLib = await import('./sentry.lib.mjs');
|
|
17
16
|
const { initializeSentry, addBreadcrumb, reportError, closeSentry } = sentryLib;
|
|
18
17
|
const { yargs, hideBin } = await initializeConfig(use);
|
|
@@ -1334,6 +1333,8 @@ try {
|
|
|
1334
1333
|
prBranch,
|
|
1335
1334
|
branchName,
|
|
1336
1335
|
tempDir,
|
|
1336
|
+
initialSessionId: sessionId,
|
|
1337
|
+
initialResultModelUsage: resultModelUsage,
|
|
1337
1338
|
argv: {
|
|
1338
1339
|
...argv,
|
|
1339
1340
|
watch: argv.watch || shouldRestart, // Enable watch if uncommitted changes
|
|
@@ -1341,7 +1342,6 @@ try {
|
|
|
1341
1342
|
},
|
|
1342
1343
|
});
|
|
1343
1344
|
|
|
1344
|
-
// Update session data with latest from watch mode for accurate pricing
|
|
1345
1345
|
if (watchResult && watchResult.latestSessionId) {
|
|
1346
1346
|
sessionId = watchResult.latestSessionId;
|
|
1347
1347
|
anthropicTotalCostUSD = watchResult.latestAnthropicCost;
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -65,6 +65,11 @@ const { trackAuthenticatedUserCommentsSince } = autoMergeHelpers;
|
|
|
65
65
|
const resultsLib = await import('./solve.results.lib.mjs');
|
|
66
66
|
const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = resultsLib;
|
|
67
67
|
|
|
68
|
+
// Issue #1056: Auto-resume on uncommitted changes — decide whether to call the
|
|
69
|
+
// agent again with --resume <sessionId> instead of restarting from scratch.
|
|
70
|
+
const autoResumeLib = await import('./auto-resume-uncommitted.lib.mjs');
|
|
71
|
+
const { decideAutoResumeOnUncommittedChanges, isAutoResumeOnUncommittedChangesEnabled } = autoResumeLib;
|
|
72
|
+
|
|
68
73
|
/**
|
|
69
74
|
* Monitor for feedback in a loop and trigger restart when detected
|
|
70
75
|
*/
|
|
@@ -76,8 +81,12 @@ export const watchForFeedback = async params => {
|
|
|
76
81
|
const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
|
|
77
82
|
|
|
78
83
|
// Track latest session data across all iterations for accurate pricing
|
|
79
|
-
|
|
84
|
+
// Issue #1056: Seed from the *initial* tool execution so the first auto-restart
|
|
85
|
+
// iteration can attempt --resume on the original session if the user opted in
|
|
86
|
+
// via --auto-resume-on-uncommitted-changes.
|
|
87
|
+
let latestSessionId = params.initialSessionId || null;
|
|
80
88
|
let latestAnthropicCost = null;
|
|
89
|
+
let latestResultModelUsage = params.initialResultModelUsage || null;
|
|
81
90
|
|
|
82
91
|
// Issue #1290: Track whether auto-restart iterations actually ran and whether logs were uploaded
|
|
83
92
|
// This helps solve.mjs decide whether to upload final logs
|
|
@@ -323,16 +332,43 @@ export const watchForFeedback = async params => {
|
|
|
323
332
|
|
|
324
333
|
let restartFeedbackLines = feedbackLines;
|
|
325
334
|
let restartArgv = argv;
|
|
326
|
-
const
|
|
335
|
+
const isUncommittedChangesRestart = isTemporaryWatch && (firstIterationInTemporaryMode || hasUncommittedInTempMode);
|
|
336
|
+
const isClaudeTool = argv.tool === 'claude' || !argv.tool;
|
|
337
|
+
const autoResumeOnUncommittedChanges = isUncommittedChangesRestart && isClaudeTool && isAutoResumeOnUncommittedChangesEnabled(argv);
|
|
338
|
+
let autoResumeDecision = null;
|
|
339
|
+
|
|
340
|
+
if (autoResumeOnUncommittedChanges) {
|
|
341
|
+
let tokenUsage = null;
|
|
342
|
+
if (latestSessionId && tempDir) {
|
|
343
|
+
try {
|
|
344
|
+
const { calculateSessionTokens } = await import('./claude.lib.mjs');
|
|
345
|
+
tokenUsage = await calculateSessionTokens(latestSessionId, tempDir, latestResultModelUsage);
|
|
346
|
+
} catch (tokenError) {
|
|
347
|
+
await log(` ⚠️ Could not calculate token usage for auto-resume decision: ${tokenError.message}`, { verbose: true });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
autoResumeDecision = decideAutoResumeOnUncommittedChanges({ argv, sessionId: latestSessionId, tokenUsage });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Keep the older issue #661 experiment working independently. The issue
|
|
354
|
+
// #1056 option takes precedence when enabled because it adds the required
|
|
355
|
+
// context-headroom safety check.
|
|
356
|
+
const legacyResumeRequested = isUncommittedChangesRestart && isClaudeTool && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart']);
|
|
357
|
+
const shouldUseSessionResume = autoResumeOnUncommittedChanges ? autoResumeDecision?.resume === true : Boolean(legacyResumeRequested && global.previousSessionId);
|
|
358
|
+
const resumeSessionId = autoResumeOnUncommittedChanges ? latestSessionId : global.previousSessionId;
|
|
327
359
|
|
|
328
360
|
if (shouldUseSessionResume) {
|
|
329
361
|
await log(formatAligned('', 'Experimental session resume: using minimal auto-restart prompt', '', 2));
|
|
330
|
-
await log(formatAligned('', `Resuming session: ${
|
|
362
|
+
await log(formatAligned('', `Resuming session: ${resumeSessionId}`, '', 2));
|
|
363
|
+
|
|
364
|
+
if (autoResumeDecision?.reason === 'ok') {
|
|
365
|
+
await log(formatAligned('', `Peak context usage: ${autoResumeDecision.peak.toLocaleString()} / ${autoResumeDecision.limit.toLocaleString()} usable tokens (${autoResumeDecision.usedPercent.toFixed(1)}%, threshold ${autoResumeDecision.threshold}%)`, '', 2));
|
|
366
|
+
}
|
|
331
367
|
|
|
332
|
-
if (argv.verbose) {
|
|
368
|
+
if (!autoResumeOnUncommittedChanges && argv.verbose) {
|
|
333
369
|
try {
|
|
334
370
|
const { calculateSessionTokens } = await import('./claude.lib.mjs');
|
|
335
|
-
const tokenUsage = await calculateSessionTokens(
|
|
371
|
+
const tokenUsage = await calculateSessionTokens(resumeSessionId, tempDir);
|
|
336
372
|
if (tokenUsage?.totalTokens) {
|
|
337
373
|
await log(formatAligned('', `Previous session tokens: ${tokenUsage.totalTokens.toLocaleString()}`, '', 2));
|
|
338
374
|
}
|
|
@@ -346,11 +382,14 @@ export const watchForFeedback = async params => {
|
|
|
346
382
|
restartFeedbackLines = [minimalPrompt];
|
|
347
383
|
restartArgv = {
|
|
348
384
|
...argv,
|
|
349
|
-
resume:
|
|
385
|
+
resume: resumeSessionId,
|
|
350
386
|
minimalRestartContext: true,
|
|
351
387
|
};
|
|
352
388
|
|
|
353
389
|
await log(formatAligned('', `Minimal restart prompt size: ${minimalPrompt.length} characters`, '', 2));
|
|
390
|
+
} else if (autoResumeOnUncommittedChanges) {
|
|
391
|
+
const skipReason = autoResumeDecision?.reason === 'context_too_full' ? `peak context usage ${autoResumeDecision.usedPercent.toFixed(1)}% reached the ${autoResumeDecision.threshold}% threshold` : autoResumeDecision?.reason === 'no_session_id' ? 'no previous session ID is available' : 'context usage could not be verified';
|
|
392
|
+
await log(formatAligned('', 'Auto-resume skipped:', `${skipReason}; starting a fresh session`, 2));
|
|
354
393
|
}
|
|
355
394
|
|
|
356
395
|
// Execute tool using shared utility
|
|
@@ -498,6 +537,12 @@ export const watchForFeedback = async params => {
|
|
|
498
537
|
}
|
|
499
538
|
}
|
|
500
539
|
|
|
540
|
+
// Issue #1056: Track latest model usage so the next auto-resume decision
|
|
541
|
+
// can re-evaluate context-window usage against the most recent peak.
|
|
542
|
+
if (toolResult.resultModelUsage) {
|
|
543
|
+
latestResultModelUsage = toolResult.resultModelUsage;
|
|
544
|
+
}
|
|
545
|
+
|
|
501
546
|
// Issue #1508: Compute budget stats for auto-restart log comment
|
|
502
547
|
let autoRestartBudgetStatsData = null;
|
|
503
548
|
if (argv.tokensBudgetStats && latestSessionId && 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
|
-
*
|
|
2
|
+
* Telegram Bot API rate-limit telemetry for `/limits` (issues #2060 and #2070).
|
|
3
3
|
*
|
|
4
|
-
* Telegram
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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 -
|
|
61
|
-
this.
|
|
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 (
|
|
170
|
+
if (event.kind === 'message') this.messageRequests++;
|
|
171
|
+
this.events.push(event);
|
|
172
|
+
this.prune(at);
|
|
67
173
|
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
250
|
+
return this.lastRateLimit;
|
|
88
251
|
}
|
|
89
252
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
253
|
+
describeRule(rule, now) {
|
|
254
|
+
const state = this.rules.get(rule.id);
|
|
255
|
+
if (state.limit === null) return null;
|
|
93
256
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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;
|