@link-assistant/hive-mind 1.69.3 → 1.69.5

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,19 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.69.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 31b0f7e: Add issue language auto-detection for solve work prompts and localize limits output.
8
+
9
+ ## 1.69.4
10
+
11
+ ### Patch Changes
12
+
13
+ - 105172b: Fix auto-PR creation failure on fork-of-fork repositories. When `solve` runs against an issue in a repository that is itself a GitHub fork and the user has direct write access, `gh pr create` previously resolved the base repository to the upstream parent (because `gh repo clone` auto-adds an `upstream` remote for forks), producing a misleading "No commits between" error. The auto-PR command builder now always passes `--repo ${owner}/${repo}` so the PR is created against the explicit target. The fatal error block also detects the failure mode and prints a fork-aware diagnostic with the resolved remotes and a manual recovery command.
14
+ - d89243f: Stabilize the version-info timing test that broke CI/CD by using the same 30 second reasonable bound as the broader version-info structure test. The version collector still runs commands in parallel, but individual commands can legally spend 5 seconds on a timeout and then another 5 seconds on a fallback, so the previous 10 second wall-clock assertion was too tight for GitHub-hosted runners.
15
+ - db56b5a: Sync custom fork base branches proactively. When a user passes `--base-branch` in fork mode, the solver now copies the requested branch from `upstream` to the user's fork before creating the issue branch, and falls back to the same recovery if branch creation still trips on a missing `origin/<baseBranch>`. This prevents the `fatal: 'origin/<baseBranch>' is not a commit` failure that surfaced for issue #1772 when an existing fork pre-dated upstream's custom branch.
16
+
3
17
  ## 1.69.3
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.3",
3
+ "version": "1.69.5",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -78,7 +78,8 @@
78
78
  "lino-arguments": "^0.3.0",
79
79
  "lino-objects-codec": "^0.3.6",
80
80
  "secretlint": "^11.2.5",
81
- "semver": "^7.7.3"
81
+ "semver": "^7.7.3",
82
+ "tinyld": "^1.3.4"
82
83
  },
83
84
  "lint-staged": {
84
85
  "*.{js,mjs,json,md}": [
@@ -0,0 +1,94 @@
1
+ import { detect as detectLanguage } from 'tinyld';
2
+
3
+ const WORD_PATTERN = /\p{L}[\p{L}\p{M}'-]*/gu;
4
+ const TARGET_LANGUAGES = new Set(['en', 'ru']);
5
+ const DEFAULT_THRESHOLD = 0.51;
6
+
7
+ export function extractLanguageWords(text) {
8
+ if (!text || typeof text !== 'string') return [];
9
+ return text.match(WORD_PATTERN) || [];
10
+ }
11
+
12
+ function detectByScript(word) {
13
+ const hasCyrillic = /\p{Script=Cyrillic}/u.test(word);
14
+ const hasLatin = /\p{Script=Latin}/u.test(word);
15
+ if (hasCyrillic && !hasLatin) return 'ru';
16
+ if (hasLatin && !hasCyrillic) return 'en';
17
+ return null;
18
+ }
19
+
20
+ export function detectWordLanguage(word, detector = detectLanguage) {
21
+ if (!word || typeof word !== 'string') return null;
22
+ let detected = null;
23
+ try {
24
+ detected = detector(word);
25
+ } catch {
26
+ detected = null;
27
+ }
28
+ if (TARGET_LANGUAGES.has(detected)) return detected;
29
+ return detectByScript(word);
30
+ }
31
+
32
+ export function detectIssueLanguageFromText(text, { detector = detectLanguage, threshold = DEFAULT_THRESHOLD, fallbackLanguage = 'en' } = {}) {
33
+ const words = extractLanguageWords(text);
34
+ const counts = { en: 0, ru: 0, detected: 0, ignored: 0, total: words.length };
35
+
36
+ for (const word of words) {
37
+ const language = detectWordLanguage(word, detector);
38
+ if (language === 'en' || language === 'ru') {
39
+ counts[language] += 1;
40
+ counts.detected += 1;
41
+ } else {
42
+ counts.ignored += 1;
43
+ }
44
+ }
45
+
46
+ const ratios = {
47
+ en: counts.total > 0 ? counts.en / counts.total : 0,
48
+ ru: counts.total > 0 ? counts.ru / counts.total : 0,
49
+ };
50
+
51
+ let language = fallbackLanguage;
52
+ if (ratios.ru > threshold) {
53
+ language = 'ru';
54
+ } else if (ratios.en > threshold) {
55
+ language = 'en';
56
+ }
57
+
58
+ return { language, counts, ratios, threshold };
59
+ }
60
+
61
+ export async function fetchTargetTextForAutoLanguage({ githubLib, owner, repo, number, isIssueUrl, isPrUrl }) {
62
+ const jsonFields = 'number,title,body';
63
+ let result = null;
64
+ if (isIssueUrl) {
65
+ result = await githubLib.ghIssueView({ issueNumber: number, owner, repo, jsonFields });
66
+ } else if (isPrUrl) {
67
+ result = await githubLib.ghPrView({ prNumber: number, owner, repo, jsonFields });
68
+ }
69
+
70
+ if (!result || result.code !== 0 || !result.data) return null;
71
+ return [result.data.title, result.data.body].filter(Boolean).join('\n\n');
72
+ }
73
+
74
+ export async function applyAutoLanguageToArgv({ argv, githubLib, owner, repo, number, isIssueUrl, isPrUrl, log = async () => {} }) {
75
+ if (!argv?.autoLanguage || argv._workLanguageExplicit) return null;
76
+
77
+ try {
78
+ const text = await fetchTargetTextForAutoLanguage({ githubLib, owner, repo, number, isIssueUrl, isPrUrl });
79
+ if (!text) {
80
+ argv.workLanguage = argv.workLanguage || 'en';
81
+ await log('Auto language detection could not fetch target text; using English work language.', { verbose: true });
82
+ return null;
83
+ }
84
+
85
+ const result = detectIssueLanguageFromText(text);
86
+ argv.workLanguage = result.language;
87
+ await log(`Auto language detection selected work language: ${result.language} (en=${result.counts.en}, ru=${result.counts.ru}, detected=${result.counts.detected})`, { verbose: true });
88
+ return result;
89
+ } catch (error) {
90
+ argv.workLanguage = argv.workLanguage || 'en';
91
+ await log(`Auto language detection failed: ${error?.message || error}; using English work language.`, { verbose: true });
92
+ return null;
93
+ }
94
+ }
@@ -0,0 +1,71 @@
1
+ import { t } from './i18n.lib.mjs';
2
+
3
+ const ENGLISH_LIMITS = {
4
+ additional_codex_limits: 'Additional Codex limits',
5
+ balance: 'balance',
6
+ claude_5_hour_session: 'Claude 5 hour session',
7
+ claude_limits: 'Claude limits',
8
+ codex_5_hour_session: 'Codex 5 hour session',
9
+ codex_credits: 'Codex credits',
10
+ codex_limits: 'Codex limits',
11
+ cpu: 'CPU',
12
+ cpu_cores_used: 'CPU cores used',
13
+ current_time: 'Current time',
14
+ current_week_all_models: 'Current week (all models)',
15
+ current_week_sonnet_only: 'Current week (Sonnet only)',
16
+ disabled_by_admin: '`--show-limits` is disabled by the bot administrator.',
17
+ disk_space: 'Disk space',
18
+ end: 'End',
19
+ five_hour_session: '5h session',
20
+ five_min_load_avg: '5m load avg',
21
+ github_api: 'GitHub API',
22
+ limits_at_end: 'Limits at end',
23
+ limits_at_start: 'Limits at start',
24
+ limits_change: 'Limits change',
25
+ na: 'N/A',
26
+ note_delta_approx: 'Note: delta is approximate (parallel sessions share the same budget).',
27
+ passed: 'passed',
28
+ plan: 'Plan',
29
+ ram: 'RAM',
30
+ requests: 'requests',
31
+ resets_at: 'Resets {{time}}',
32
+ resets_in: 'Resets in {{duration}}',
33
+ seven_day_all_models: '7d all models',
34
+ seven_day_sonnet_only: '7d Sonnet only',
35
+ session: 'session',
36
+ start: 'Start',
37
+ unavailable: 'unavailable',
38
+ unlimited: 'unlimited',
39
+ used: 'used',
40
+ week: 'week',
41
+ weekly: 'Weekly',
42
+ };
43
+
44
+ function applyParams(text, params = {}) {
45
+ let out = text;
46
+ for (const [key, value] of Object.entries(params)) {
47
+ out = out.replace(new RegExp(`{{${key}}}`, 'g'), String(value));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function resolveLimitLocale(options = {}) {
53
+ if (typeof options === 'string') return options;
54
+ return options?.locale || null;
55
+ }
56
+
57
+ export function lt(key, params = {}, options = {}) {
58
+ const fullKey = `limits.${key}`;
59
+ const locale = resolveLimitLocale(options);
60
+ const translated = t(fullKey, params, locale ? { locale } : {});
61
+ if (translated !== fullKey) return translated;
62
+ return applyParams(ENGLISH_LIMITS[key] || key, params);
63
+ }
64
+
65
+ export function formatLimitResetsIn(duration, resetTime, options = {}) {
66
+ return `${lt('resets_in', { duration }, options)} (${resetTime})`;
67
+ }
68
+
69
+ export function formatLimitResetsAt(resetTime, options = {}) {
70
+ return lt('resets_at', { time: resetTime }, options);
71
+ }
@@ -13,6 +13,7 @@ import dayjs from 'dayjs';
13
13
  import utc from 'dayjs/plugin/utc.js';
14
14
 
15
15
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
+ import { formatLimitResetsAt, formatLimitResetsIn, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
16
17
  // Initialize dayjs plugins
17
18
  dayjs.extend(utc);
18
19
 
@@ -280,13 +281,14 @@ function formatBytes(bytes) {
280
281
  }
281
282
 
282
283
  /**
283
- * Format two byte values into a combined "used/total UNIT used" format
284
284
  * @param {number} usedBytes - Used size in bytes
285
285
  * @param {number} totalBytes - Total size in bytes
286
+ * @param {Object|string} options - Optional locale options
286
287
  * @returns {string} Formatted string (e.g., "2.8/11.7 GB used")
287
288
  */
288
- function formatBytesRange(usedBytes, totalBytes) {
289
- if (totalBytes === 0) return '0/0 B used';
289
+ function formatBytesRange(usedBytes, totalBytes, options = {}) {
290
+ const usedLabel = lt('used', {}, options);
291
+ if (totalBytes === 0) return `0/0 B ${usedLabel}`;
290
292
  const k = 1024;
291
293
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
292
294
  // Determine unit based on total (larger value)
@@ -295,7 +297,7 @@ function formatBytesRange(usedBytes, totalBytes) {
295
297
  const totalValue = totalBytes / Math.pow(k, i);
296
298
  // Use 1 decimal place for GB and above, none for smaller units
297
299
  const decimals = i >= 3 ? 1 : 0;
298
- return `${usedValue.toFixed(decimals)}/${totalValue.toFixed(decimals)} ${sizes[i]} used`;
300
+ return `${usedValue.toFixed(decimals)}/${totalValue.toFixed(decimals)} ${sizes[i]} ${usedLabel}`;
299
301
  }
300
302
 
301
303
  function formatRoundedNumber(value, decimals = 2) {
@@ -307,6 +309,10 @@ function getDisplayCpuCoresUsed(loadAvg5, cpuCount) {
307
309
  return formatRoundedNumber(boundedLoad);
308
310
  }
309
311
 
312
+ function hasLimitPercentage(window) {
313
+ return window?.percentage !== null && window?.percentage !== undefined;
314
+ }
315
+
310
316
  /**
311
317
  * Get GitHub API rate limits by calling gh api rate_limit
312
318
  * Returns rate limit info for core, search, graphql, and other resources
@@ -1019,74 +1025,68 @@ export function calculateTimePassedPercentage(resetsAt, periodHours) {
1019
1025
  * @param {Object} memory - Optional memory info from getMemoryInfo
1020
1026
  * @param {string|null} claudeError - Optional error message to show in Claude sections (e.g., auth expired)
1021
1027
  * @param {string[]} extraSections - Optional extra sections to append inside the code block (e.g. queue status)
1028
+ * @param {Object|string} options - Optional locale options
1022
1029
  * @returns {string} Formatted message wrapped in a single code block
1023
1030
  * @see https://github.com/link-assistant/hive-mind/issues/1242
1024
1031
  */
1025
- export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = null, cpuLoad = null, memory = null, claudeError = null, extraSections = []) {
1026
- // Build sections as individual text blocks; they will all be joined and wrapped in a
1027
- // single code block at the end. This avoids fragile string-searching to inject content.
1028
-
1032
+ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = null, cpuLoad = null, memory = null, claudeError = null, extraSections = [], options = {}) {
1033
+ if (!Array.isArray(extraSections) && extraSections && typeof extraSections === 'object') {
1034
+ options = extraSections;
1035
+ extraSections = [];
1036
+ }
1037
+ const locale = resolveLimitLocale(options);
1029
1038
  const sections = [];
1030
1039
 
1031
- // Show current time
1032
- sections.push(`Current time: ${formatCurrentTime()}\n`);
1040
+ sections.push(`${lt('current_time', {}, { locale })}: ${formatCurrentTime()}\n`);
1033
1041
 
1034
- // CPU load section (if provided)
1035
- // Threshold: Blocks new commands when usage >= 65%
1036
1042
  if (cpuLoad) {
1037
- let section = 'CPU\n';
1043
+ let section = `${lt('cpu', {}, { locale })}\n`;
1038
1044
  const usedBar = getProgressBar(cpuLoad.usagePercentage, DISPLAY_THRESHOLDS.CPU);
1039
1045
  // Show 'used' label when below threshold, warning emoji when at/above threshold
1040
1046
  // See: https://github.com/link-assistant/hive-mind/issues/1267
1041
- const suffix = cpuLoad.usagePercentage >= DISPLAY_THRESHOLDS.CPU ? ' ⚠️' : ' used';
1047
+ const suffix = cpuLoad.usagePercentage >= DISPLAY_THRESHOLDS.CPU ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1042
1048
  section += `${usedBar} ${cpuLoad.usagePercentage}%${suffix}\n`;
1043
1049
  // Linux load average is demand, not bounded CPU time. Keep the cores-used
1044
1050
  // display within CPU capacity and show raw load average only when saturated.
1045
1051
  const usedCpuCores = cpuLoad.usedCpuCores ?? getDisplayCpuCoresUsed(cpuLoad.loadAvg5, cpuLoad.cpuCount);
1046
- let cpuCoresLine = `${formatRoundedNumber(usedCpuCores)}/${cpuLoad.cpuCount} CPU cores used`;
1052
+ let cpuCoresLine = `${formatRoundedNumber(usedCpuCores)}/${cpuLoad.cpuCount} ${lt('cpu_cores_used', {}, { locale })}`;
1047
1053
  if (cpuLoad.loadAvg5 > cpuLoad.cpuCount) {
1048
- cpuCoresLine += ` (5m load avg ${formatRoundedNumber(cpuLoad.loadAvg5)})`;
1054
+ cpuCoresLine += ` (${lt('five_min_load_avg', {}, { locale })} ${formatRoundedNumber(cpuLoad.loadAvg5)})`;
1049
1055
  }
1050
1056
  section += `${cpuCoresLine}\n`;
1051
1057
  sections.push(section);
1052
1058
  }
1053
1059
 
1054
- // Memory section (if provided)
1055
- // Threshold: Blocks new commands when usage >= 65%
1056
1060
  if (memory) {
1057
- let section = 'RAM\n';
1061
+ let section = `${lt('ram', {}, { locale })}\n`;
1058
1062
  const usedBar = getProgressBar(memory.usedPercentage, DISPLAY_THRESHOLDS.RAM);
1059
- const suffix = memory.usedPercentage >= DISPLAY_THRESHOLDS.RAM ? ' ⚠️' : ' used';
1063
+ const suffix = memory.usedPercentage >= DISPLAY_THRESHOLDS.RAM ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1060
1064
  section += `${usedBar} ${memory.usedPercentage}%${suffix}\n`;
1061
- section += `${formatBytesRange(memory.usedBytes, memory.totalBytes)}\n`;
1065
+ section += `${formatBytesRange(memory.usedBytes, memory.totalBytes, { locale })}\n`;
1062
1066
  sections.push(section);
1063
1067
  }
1064
1068
 
1065
- // Disk space section (if provided)
1066
- // Threshold: One-at-a-time mode when usage >= 90%
1067
1069
  if (diskSpace) {
1068
- let section = 'Disk space\n';
1069
- // Show used percentage with progress bar and threshold marker
1070
+ let section = `${lt('disk_space', {}, { locale })}\n`;
1070
1071
  const usedBar = getProgressBar(diskSpace.usedPercentage, DISPLAY_THRESHOLDS.DISK);
1071
- const suffix = diskSpace.usedPercentage >= DISPLAY_THRESHOLDS.DISK ? ' ⚠️' : ' used';
1072
+ const suffix = diskSpace.usedPercentage >= DISPLAY_THRESHOLDS.DISK ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1072
1073
  section += `${usedBar} ${diskSpace.usedPercentage}%${suffix}\n`;
1073
- section += `${formatBytesRange(diskSpace.usedBytes, diskSpace.totalBytes)}\n`;
1074
+ section += `${formatBytesRange(diskSpace.usedBytes, diskSpace.totalBytes, { locale })}\n`;
1074
1075
  sections.push(section);
1075
1076
  }
1076
1077
 
1077
1078
  // GitHub API rate limits section (if provided)
1078
1079
  // Threshold: Blocks parallel claude commands when >= 75%
1079
1080
  if (githubRateLimit) {
1080
- let section = 'GitHub API\n';
1081
- // Show used percentage with progress bar and threshold marker
1081
+ let section = `${lt('github_api', {}, { locale })}\n`;
1082
1082
  const usedBar = getProgressBar(githubRateLimit.usedPercentage, DISPLAY_THRESHOLDS.GITHUB_API);
1083
- const suffix = githubRateLimit.usedPercentage >= DISPLAY_THRESHOLDS.GITHUB_API ? ' ⚠️' : ' used';
1083
+ const suffix = githubRateLimit.usedPercentage >= DISPLAY_THRESHOLDS.GITHUB_API ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1084
1084
  section += `${usedBar} ${githubRateLimit.usedPercentage}%${suffix}\n`;
1085
- section += `${githubRateLimit.used}/${githubRateLimit.limit} requests\n`;
1085
+ section += `${githubRateLimit.used}/${githubRateLimit.limit} ${lt('requests', {}, { locale })}\n`;
1086
1086
  if (githubRateLimit.relativeReset) {
1087
- section += `Resets in ${githubRateLimit.relativeReset} (${githubRateLimit.resetTime})\n`;
1087
+ section += `${formatLimitResetsIn(githubRateLimit.relativeReset, githubRateLimit.resetTime, { locale })}\n`;
1088
1088
  } else if (githubRateLimit.resetTime) {
1089
- section += `Resets ${githubRateLimit.resetTime}\n`;
1089
+ section += `${formatLimitResetsAt(githubRateLimit.resetTime, { locale })}\n`;
1090
1090
  }
1091
1091
  sections.push(section);
1092
1092
  }
@@ -1094,81 +1094,77 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1094
1094
  // Claude limits section
1095
1095
  // When there's an error (e.g., auth expired), show it once and skip empty subsections
1096
1096
  if (claudeError) {
1097
- sections.push(`Claude limits\n${claudeError}\n`);
1097
+ sections.push(`${lt('claude_limits', {}, { locale })}\n${claudeError}\n`);
1098
1098
  } else {
1099
1099
  // Claude 5 hour session (five_hour)
1100
1100
  // Threshold: One-at-a-time mode when usage >= 65%
1101
- let sessionSection = 'Claude 5 hour session\n';
1102
- if (usage && usage.currentSession.percentage !== null) {
1103
- // Add time passed progress bar first (no threshold marker for time)
1101
+ let sessionSection = `${lt('claude_5_hour_session', {}, { locale })}\n`;
1102
+ if (hasLimitPercentage(usage?.currentSession)) {
1104
1103
  const timePassed = calculateTimePassedPercentage(usage.currentSession.resetsAt, 5);
1105
1104
  if (timePassed !== null) {
1106
1105
  const timeBar = getProgressBar(timePassed);
1107
- sessionSection += `${timeBar} ${timePassed}% passed\n`;
1106
+ sessionSection += `${timeBar} ${timePassed}% ${lt('passed', {}, { locale })}\n`;
1108
1107
  }
1109
1108
 
1110
- // Add usage progress bar second with threshold marker
1111
1109
  // Use Math.floor so 100% only appears when usage is exactly 100%
1112
1110
  // See: https://github.com/link-assistant/hive-mind/issues/1133
1113
1111
  const pct = Math.floor(usage.currentSession.percentage);
1114
1112
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION);
1115
- const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION ? ' ⚠️' : ' used';
1113
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1116
1114
  sessionSection += `${bar} ${pct}%${suffix}\n`;
1117
1115
 
1118
1116
  if (usage.currentSession.resetTime) {
1119
1117
  const relativeTime = formatRelativeTime(usage.currentSession.resetsAt);
1120
1118
  if (relativeTime) {
1121
- sessionSection += `Resets in ${relativeTime} (${usage.currentSession.resetTime})\n`;
1119
+ sessionSection += `${formatLimitResetsIn(relativeTime, usage.currentSession.resetTime, { locale })}\n`;
1122
1120
  } else {
1123
- sessionSection += `Resets ${usage.currentSession.resetTime}\n`;
1121
+ sessionSection += `${formatLimitResetsAt(usage.currentSession.resetTime, { locale })}\n`;
1124
1122
  }
1125
1123
  }
1126
1124
  } else {
1127
- sessionSection += 'N/A\n';
1125
+ sessionSection += `${lt('na', {}, { locale })}\n`;
1128
1126
  }
1129
1127
  sections.push(sessionSection);
1130
1128
 
1131
1129
  // Current week (all models / seven_day)
1132
1130
  // Threshold: One-at-a-time mode when usage >= 97%
1133
- let allModelsSection = 'Current week (all models)\n';
1134
- if (usage && usage.allModels.percentage !== null) {
1135
- // Add time passed progress bar first (no threshold marker for time)
1131
+ let allModelsSection = `${lt('current_week_all_models', {}, { locale })}\n`;
1132
+ if (hasLimitPercentage(usage?.allModels)) {
1136
1133
  const timePassed = calculateTimePassedPercentage(usage.allModels.resetsAt, 168);
1137
1134
  if (timePassed !== null) {
1138
1135
  const timeBar = getProgressBar(timePassed);
1139
- allModelsSection += `${timeBar} ${timePassed}% passed\n`;
1136
+ allModelsSection += `${timeBar} ${timePassed}% ${lt('passed', {}, { locale })}\n`;
1140
1137
  }
1141
1138
 
1142
- // Add usage progress bar second with threshold marker
1143
1139
  // Use Math.floor so 100% only appears when usage is exactly 100%
1144
1140
  // See: https://github.com/link-assistant/hive-mind/issues/1133
1145
1141
  const pct = Math.floor(usage.allModels.percentage);
1146
1142
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_WEEKLY);
1147
- const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ' used';
1143
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1148
1144
  allModelsSection += `${bar} ${pct}%${suffix}\n`;
1149
1145
 
1150
1146
  if (usage.allModels.resetTime) {
1151
1147
  const relativeTime = formatRelativeTime(usage.allModels.resetsAt);
1152
1148
  if (relativeTime) {
1153
- allModelsSection += `Resets in ${relativeTime} (${usage.allModels.resetTime})\n`;
1149
+ allModelsSection += `${formatLimitResetsIn(relativeTime, usage.allModels.resetTime, { locale })}\n`;
1154
1150
  } else {
1155
- allModelsSection += `Resets ${usage.allModels.resetTime}\n`;
1151
+ allModelsSection += `${formatLimitResetsAt(usage.allModels.resetTime, { locale })}\n`;
1156
1152
  }
1157
1153
  }
1158
1154
  } else {
1159
- allModelsSection += 'N/A\n';
1155
+ allModelsSection += `${lt('na', {}, { locale })}\n`;
1160
1156
  }
1161
1157
  sections.push(allModelsSection);
1162
1158
 
1163
1159
  // Current week (Sonnet only / seven_day_sonnet)
1164
1160
  // Threshold: One-at-a-time mode when usage >= 97% (same as all models)
1165
- let sonnetSection = 'Current week (Sonnet only)\n';
1166
- if (usage && usage.sonnetOnly.percentage !== null) {
1161
+ let sonnetSection = `${lt('current_week_sonnet_only', {}, { locale })}\n`;
1162
+ if (hasLimitPercentage(usage?.sonnetOnly)) {
1167
1163
  // Add time passed progress bar first (no threshold marker for time)
1168
1164
  const timePassed = calculateTimePassedPercentage(usage.sonnetOnly.resetsAt, 168);
1169
1165
  if (timePassed !== null) {
1170
1166
  const timeBar = getProgressBar(timePassed);
1171
- sonnetSection += `${timeBar} ${timePassed}% passed\n`;
1167
+ sonnetSection += `${timeBar} ${timePassed}% ${lt('passed', {}, { locale })}\n`;
1172
1168
  }
1173
1169
 
1174
1170
  // Add usage progress bar second with threshold marker
@@ -1176,19 +1172,19 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1176
1172
  // See: https://github.com/link-assistant/hive-mind/issues/1133
1177
1173
  const pct = Math.floor(usage.sonnetOnly.percentage);
1178
1174
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_WEEKLY);
1179
- const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ' used';
1175
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1180
1176
  sonnetSection += `${bar} ${pct}%${suffix}\n`;
1181
1177
 
1182
1178
  if (usage.sonnetOnly.resetTime) {
1183
1179
  const relativeTime = formatRelativeTime(usage.sonnetOnly.resetsAt);
1184
1180
  if (relativeTime) {
1185
- sonnetSection += `Resets in ${relativeTime} (${usage.sonnetOnly.resetTime})\n`;
1181
+ sonnetSection += `${formatLimitResetsIn(relativeTime, usage.sonnetOnly.resetTime, { locale })}\n`;
1186
1182
  } else {
1187
- sonnetSection += `Resets ${usage.sonnetOnly.resetTime}\n`;
1183
+ sonnetSection += `${formatLimitResetsAt(usage.sonnetOnly.resetTime, { locale })}\n`;
1188
1184
  }
1189
1185
  }
1190
1186
  } else {
1191
- sonnetSection += 'N/A\n';
1187
+ sonnetSection += `${lt('na', {}, { locale })}\n`;
1192
1188
  }
1193
1189
  sections.push(sonnetSection);
1194
1190
  }
@@ -1208,11 +1204,13 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1208
1204
  *
1209
1205
  * @param {Object|null} codexLimits - Result object from getCodexUsageLimits, or null
1210
1206
  * @param {string|null} codexError - Optional error message
1207
+ * @param {Object|string} options - Optional locale options
1211
1208
  * @returns {string} Formatted section text
1212
1209
  */
1213
- export function formatCodexLimitsSection(codexLimits, codexError = null) {
1210
+ export function formatCodexLimitsSection(codexLimits, codexError = null, options = {}) {
1211
+ const locale = resolveLimitLocale(options);
1214
1212
  if (codexError) {
1215
- return `Codex limits\n${codexError}\n`;
1213
+ return `${lt('codex_limits', {}, { locale })}\n${codexError}\n`;
1216
1214
  }
1217
1215
 
1218
1216
  const usage = codexLimits?.usage || null;
@@ -1220,63 +1218,63 @@ export function formatCodexLimitsSection(codexLimits, codexError = null) {
1220
1218
  const credits = codexLimits?.credits || null;
1221
1219
  const planType = codexLimits?.planType || null;
1222
1220
 
1223
- let section = 'Codex limits\n';
1221
+ let section = `${lt('codex_limits', {}, { locale })}\n`;
1224
1222
  if (planType) {
1225
- section += `Plan: ${planType}\n`;
1223
+ section += `${lt('plan', {}, { locale })}: ${planType}\n`;
1226
1224
  }
1227
1225
 
1228
- let sessionSection = 'Codex 5 hour session\n';
1229
- if (usage?.currentSession?.percentage !== null) {
1226
+ let sessionSection = `${lt('codex_5_hour_session', {}, { locale })}\n`;
1227
+ if (hasLimitPercentage(usage?.currentSession)) {
1230
1228
  const timePassed = calculateTimePassedPercentage(usage.currentSession.resetsAt, 5);
1231
1229
  if (timePassed !== null) {
1232
- sessionSection += `${getProgressBar(timePassed)} ${timePassed}% passed\n`;
1230
+ sessionSection += `${getProgressBar(timePassed)} ${timePassed}% ${lt('passed', {}, { locale })}\n`;
1233
1231
  }
1234
1232
  const pct = Math.floor(usage.currentSession.percentage);
1235
1233
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION);
1236
- const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION ? ' ⚠️' : ' used';
1234
+ const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1237
1235
  sessionSection += `${bar} ${pct}%${suffix}\n`;
1238
1236
  if (usage.currentSession.resetTime) {
1239
1237
  const relativeTime = formatRelativeTime(usage.currentSession.resetsAt);
1240
- sessionSection += relativeTime ? `Resets in ${relativeTime} (${usage.currentSession.resetTime})\n` : `Resets ${usage.currentSession.resetTime}\n`;
1238
+ sessionSection += relativeTime ? `${formatLimitResetsIn(relativeTime, usage.currentSession.resetTime, { locale })}\n` : `${formatLimitResetsAt(usage.currentSession.resetTime, { locale })}\n`;
1241
1239
  }
1242
1240
  } else {
1243
- sessionSection += 'N/A\n';
1241
+ sessionSection += `${lt('na', {}, { locale })}\n`;
1244
1242
  }
1245
1243
 
1246
- let weeklySection = 'Current week (all models)\n';
1247
- if (usage?.allModels?.percentage !== null) {
1244
+ let weeklySection = `${lt('current_week_all_models', {}, { locale })}\n`;
1245
+ if (hasLimitPercentage(usage?.allModels)) {
1248
1246
  const timePassed = calculateTimePassedPercentage(usage.allModels.resetsAt, 168);
1249
1247
  if (timePassed !== null) {
1250
- weeklySection += `${getProgressBar(timePassed)} ${timePassed}% passed\n`;
1248
+ weeklySection += `${getProgressBar(timePassed)} ${timePassed}% ${lt('passed', {}, { locale })}\n`;
1251
1249
  }
1252
1250
  const pct = Math.floor(usage.allModels.percentage);
1253
1251
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CODEX_WEEKLY);
1254
- const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_WEEKLY ? ' ⚠️' : ' used';
1252
+ const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_WEEKLY ? ' ⚠️' : ` ${lt('used', {}, { locale })}`;
1255
1253
  weeklySection += `${bar} ${pct}%${suffix}\n`;
1256
1254
  if (usage.allModels.resetTime) {
1257
1255
  const relativeTime = formatRelativeTime(usage.allModels.resetsAt);
1258
- weeklySection += relativeTime ? `Resets in ${relativeTime} (${usage.allModels.resetTime})\n` : `Resets ${usage.allModels.resetTime}\n`;
1256
+ weeklySection += relativeTime ? `${formatLimitResetsIn(relativeTime, usage.allModels.resetTime, { locale })}\n` : `${formatLimitResetsAt(usage.allModels.resetTime, { locale })}\n`;
1259
1257
  }
1260
1258
  } else {
1261
- weeklySection += 'N/A\n';
1259
+ weeklySection += `${lt('na', {}, { locale })}\n`;
1262
1260
  }
1263
1261
 
1264
1262
  section += `${sessionSection}\n${weeklySection}`;
1265
1263
 
1266
1264
  if (additionalRateLimits.length > 0) {
1267
- section += '\nAdditional Codex limits\n';
1265
+ section += `\n${lt('additional_codex_limits', {}, { locale })}\n`;
1268
1266
  for (const limit of additionalRateLimits) {
1269
1267
  const sessionPct = limit.currentSession?.percentage;
1270
1268
  const weeklyPct = limit.allModels?.percentage;
1271
- const sessionText = sessionPct === null ? 'session N/A' : `session ${Math.floor(sessionPct)}%`;
1272
- const weeklyText = weeklyPct === null ? 'week N/A' : `week ${Math.floor(weeklyPct)}%`;
1269
+ const sessionText = sessionPct === null || sessionPct === undefined ? `${lt('session', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('session', {}, { locale })} ${Math.floor(sessionPct)}%`;
1270
+ const weeklyText = weeklyPct === null || weeklyPct === undefined ? `${lt('week', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('week', {}, { locale })} ${Math.floor(weeklyPct)}%`;
1273
1271
  section += `${limit.limitName}: ${sessionText}, ${weeklyText}\n`;
1274
1272
  }
1275
1273
  }
1276
1274
 
1277
1275
  if (credits) {
1278
- const creditSummary = credits.unlimited ? 'unlimited' : `${credits.balance ?? '0'} balance`;
1279
- section += `\nCodex credits\n${creditSummary}\n`;
1276
+ const creditSummary = credits.unlimited ? lt('unlimited', {}, { locale }) : `${credits.balance ?? '0'} ${lt('balance', {}, { locale })}`;
1277
+ section += `\n${lt('codex_credits', {}, { locale })}\n${creditSummary}\n`;
1280
1278
  }
1281
1279
 
1282
1280
  return section;