@link-assistant/hive-mind 1.56.5 → 1.56.7

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.
@@ -45,7 +45,7 @@ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = awa
45
45
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
46
46
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
47
47
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
48
- const { applySolveToolAlias, getSolveCommandNameFromText, getSolveToolAliasFromText, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
48
+ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
49
49
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
50
50
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
51
51
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
@@ -500,11 +500,18 @@ function mergeArgsWithOverrides(userArgs, overrides) {
500
500
  }
501
501
 
502
502
  /** Validate GitHub URL for Telegram bot commands. Returns { valid, error?, parsed?, normalizedUrl? } */
503
- function validateGitHubUrl(args, options = {}) {
504
- const { allowedTypes = ['issue', 'pull'], commandName = 'solve' } = options;
505
- if (args.length === 0) return { valid: false, error: `Missing GitHub URL. Usage: /${commandName} <github-url> [options]` };
503
+ async function getCommandUrlArg(args, createYargsConfig, positionalNames) {
504
+ const parsedUrl = createYargsConfig ? await getFirstParsedPositionalArg(args, yargs, createYargsConfig, positionalNames) : null;
505
+ if (parsedUrl) return parsedUrl;
506
+ return args.find(arg => cleanNonPrintableChars(arg).includes('github.com')) || (args[0] && !args[0].startsWith('-') ? args[0] : null);
507
+ }
508
+
509
+ async function validateGitHubUrl(args, options = {}) {
510
+ const { allowedTypes = ['issue', 'pull'], commandName = 'solve', createYargsConfig = null, positionalNames = [] } = options;
511
+ const rawUrl = await getCommandUrlArg(args, createYargsConfig, positionalNames);
512
+ if (!rawUrl) return { valid: false, error: `Missing GitHub URL. Usage: /${commandName} <github-url> [options]` };
506
513
  // Issue #1102: Clean non-printable chars (Zero-Width Space, BOM, etc.) from URLs
507
- const url = cleanNonPrintableChars(args[0]);
514
+ const url = cleanNonPrintableChars(rawUrl);
508
515
  if (!url.includes('github.com')) return { valid: false, error: 'First argument must be a GitHub URL' };
509
516
  const parsed = parseGitHubUrl(url);
510
517
  if (!parsed.valid) return { valid: false, error: parsed.error || 'Invalid GitHub URL', suggestion: parsed.suggestion };
@@ -842,11 +849,12 @@ async function handleSolveCommand(ctx) {
842
849
  // Issue #1325: Support all options via /solve command when replying (e.g., "/solve --model opus")
843
850
  const isReply = message.reply_to_message && message.reply_to_message.message_id && !message.reply_to_message.forum_topic_created;
844
851
 
845
- // Check if the first argument looks like a GitHub URL
846
- // If not, we should try to extract the URL from the replied message
847
- const firstArgIsUrl = userArgs.length > 0 && (userArgs[0].includes('github.com') || userArgs[0].match(/^https?:\/\//));
852
+ // Check if yargs sees a command URL. If not, try to extract it from the replied message.
853
+ const commandUrlArg = await getCommandUrlArg(userArgs, createSolveYargsConfig, ['issue-url']);
854
+ const commandUrlText = commandUrlArg ? cleanNonPrintableChars(commandUrlArg) : '';
855
+ const commandHasUrl = commandUrlText.includes('github.com') || /^https?:\/\//.test(commandUrlText);
848
856
 
849
- if (isReply && !firstArgIsUrl) {
857
+ if (isReply && !commandHasUrl) {
850
858
  if (VERBOSE) {
851
859
  console.log('[VERBOSE] /solve is a reply without URL in args, extracting from replied message...');
852
860
  console.log('[VERBOSE] User args:', userArgs);
@@ -883,7 +891,13 @@ async function handleSolveCommand(ctx) {
883
891
 
884
892
  userArgs = applySolveToolAlias(userArgs, solveToolAlias);
885
893
 
886
- const validation = validateGitHubUrl(userArgs);
894
+ const { malformed, errors: malformedErrors } = detectMalformedFlags(userArgs);
895
+ if (malformed.length > 0) {
896
+ await safeReply(ctx, `❌ ${escapeMarkdown(malformedErrors.join('\n'))}\n\nPlease check your option syntax.`, { reply_to_message_id: ctx.message.message_id });
897
+ return;
898
+ }
899
+
900
+ const validation = await validateGitHubUrl(userArgs, { createYargsConfig: createSolveYargsConfig, positionalNames: ['issue-url'] });
887
901
  if (!validation.valid) {
888
902
  let errorMsg = `❌ ${validation.error}`;
889
903
  if (validation.suggestion) {
@@ -893,6 +907,7 @@ async function handleSolveCommand(ctx) {
893
907
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
894
908
  return;
895
909
  }
910
+ userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
896
911
  const { backend: solvePerCommandIsolation, filteredArgs: userArgsWithoutIsolation } = extractIsolationFromArgs(userArgs); // issue #1534
897
912
  if (solvePerCommandIsolation && !isValidPerCommandIsolation(solvePerCommandIsolation)) {
898
913
  await safeReply(ctx, `❌ Invalid --isolation value '${escapeMarkdown(solvePerCommandIsolation)}'. Must be: screen, tmux, or docker`, { reply_to_message_id: ctx.message.message_id });
@@ -928,27 +943,14 @@ async function handleSolveCommand(ctx) {
928
943
  await safeReply(ctx, `❌ ${escapeMarkdown(branchError)}`, { reply_to_message_id: ctx.message.message_id });
929
944
  return;
930
945
  }
931
- // Issue #1092: Detect malformed flag patterns like "-- model" (space after --)
932
- const { malformed, errors: malformedErrors } = detectMalformedFlags(args);
933
- if (malformed.length > 0) {
934
- await safeReply(ctx, `❌ ${escapeMarkdown(malformedErrors.join('\n'))}\n\nPlease check your option syntax.`, { reply_to_message_id: ctx.message.message_id });
946
+ const { malformed: mergedMalformed, errors: mergedMalformedErrors } = detectMalformedFlags(args);
947
+ if (mergedMalformed.length > 0) {
948
+ await safeReply(ctx, `❌ ${escapeMarkdown(mergedMalformedErrors.join('\n'))}\n\nPlease check your option syntax.`, { reply_to_message_id: ctx.message.message_id });
935
949
  return;
936
950
  }
937
951
  // Validate merged arguments using solve's yargs config
938
952
  try {
939
- // Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
940
- const testYargs = createSolveYargsConfig(yargs());
941
-
942
- // Configure yargs to throw errors instead of trying to exit the process
943
- // This prevents confusing error messages when validation fails but execution continues
944
- let failureMessage = null;
945
- testYargs.exitProcess(false).fail((msg, err) => {
946
- // Capture the failure message instead of letting yargs print it
947
- failureMessage = msg || (err && err.message) || 'Unknown validation error';
948
- throw new Error(failureMessage);
949
- });
950
-
951
- testYargs.parse(args);
953
+ await parseArgsWithYargs(args, yargs, createSolveYargsConfig);
952
954
  } catch (error) {
953
955
  await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, {
954
956
  reply_to_message_id: ctx.message.message_id,
@@ -1095,7 +1097,7 @@ async function handleHiveCommand(ctx) {
1095
1097
  const userArgs = parseCommandArgs(ctx.message.text);
1096
1098
 
1097
1099
  // Issue #1102: Allow issues_list/pulls_list URLs and normalize to repo URLs
1098
- const validation = validateGitHubUrl(userArgs, { allowedTypes: ['repo', 'organization', 'user', 'issues_list', 'pulls_list'], commandName: 'hive' });
1100
+ const validation = await validateGitHubUrl(userArgs, { allowedTypes: ['repo', 'organization', 'user', 'issues_list', 'pulls_list'], commandName: 'hive', createYargsConfig: createHiveYargsConfig, positionalNames: ['github-url'] });
1099
1101
  if (!validation.valid) {
1100
1102
  let errorMsg = `❌ ${validation.error}`;
1101
1103
  if (validation.suggestion) errorMsg += `\n\n💡 Did you mean: \`${escapeMarkdown(validation.suggestion)}\``;
@@ -1104,7 +1106,7 @@ async function handleHiveCommand(ctx) {
1104
1106
  return;
1105
1107
  }
1106
1108
  // Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
1107
- let normalizedArgs = [...userArgs];
1109
+ let normalizedArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
1108
1110
  const p = validation.parsed;
1109
1111
  if (p && (p.type === 'issues_list' || p.type === 'pulls_list')) {
1110
1112
  normalizedArgs[0] = `https://github.com/${p.owner}/${p.repo}`;
@@ -1149,19 +1151,7 @@ async function handleHiveCommand(ctx) {
1149
1151
 
1150
1152
  // Validate merged arguments using hive's yargs config
1151
1153
  try {
1152
- // Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
1153
- const testYargs = createHiveYargsConfig(yargs());
1154
-
1155
- // Configure yargs to throw errors instead of trying to exit the process
1156
- // This prevents confusing error messages when validation fails but execution continues
1157
- let failureMessage = null;
1158
- testYargs.exitProcess(false).fail((msg, err) => {
1159
- // Capture the failure message instead of letting yargs print it
1160
- failureMessage = msg || (err && err.message) || 'Unknown validation error';
1161
- throw new Error(failureMessage);
1162
- });
1163
-
1164
- testYargs.parse(args);
1154
+ await parseArgsWithYargs(args, yargs, createHiveYargsConfig);
1165
1155
  } catch (error) {
1166
1156
  await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, {
1167
1157
  reply_to_message_id: ctx.message.message_id,
@@ -59,6 +59,64 @@ export function parseCommandArgs(text) {
59
59
  return args;
60
60
  }
61
61
 
62
+ function toCamelCaseOptionName(name) {
63
+ return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
64
+ }
65
+
66
+ export function getYargsPositionalArg(argv, positionalNames = []) {
67
+ if (!argv || typeof argv !== 'object') return null;
68
+
69
+ for (const name of positionalNames) {
70
+ const aliases = [name, toCamelCaseOptionName(name)];
71
+ for (const alias of aliases) {
72
+ if (typeof argv[alias] === 'string' && argv[alias].trim()) return argv[alias];
73
+ }
74
+ }
75
+
76
+ if (Array.isArray(argv._)) {
77
+ return argv._.find(value => typeof value === 'string' && value.trim()) || null;
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig) {
84
+ const originalStderrWrite = process.stderr.write;
85
+ process.stderr.write = (_chunk, encoding, callback) => {
86
+ if (typeof encoding === 'function') encoding();
87
+ else if (typeof callback === 'function') callback();
88
+ return true;
89
+ };
90
+ try {
91
+ const parser = createYargsConfig(yargsFactory());
92
+ parser
93
+ .exitProcess(false)
94
+ .showHelpOnFail(false)
95
+ .fail((msg, err) => {
96
+ throw err || new Error(msg || 'Invalid arguments');
97
+ });
98
+ return await parser.parse(args);
99
+ } finally {
100
+ process.stderr.write = originalStderrWrite;
101
+ }
102
+ }
103
+
104
+ export async function getFirstParsedPositionalArg(args, yargsFactory, createYargsConfig, positionalNames = []) {
105
+ try {
106
+ return getYargsPositionalArg(await parseArgsWithYargs(args, yargsFactory, createYargsConfig), positionalNames);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ export function moveArgumentToFront(args, target, normalize = value => value) {
113
+ if (!target) return [...args];
114
+ const normalizedTarget = normalize(target);
115
+ const index = args.findIndex(arg => normalize(arg) === normalizedTarget);
116
+ if (index < 0) return [normalizedTarget, ...args];
117
+ return [normalizedTarget, ...args.slice(0, index), ...args.slice(index + 1)];
118
+ }
119
+
62
120
  export function getSolveCommandNameFromText(text) {
63
121
  if (!text || typeof text !== 'string') {
64
122
  return null;
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { retryLimits } from './config.lib.mjs';
4
+ import { resolveDefaultFallbackModel, resolveModelId } from './models/index.mjs';
5
+
6
+ const normalizeMessage = value => {
7
+ if (value === null || value === undefined) return '';
8
+ if (typeof value === 'string') return value;
9
+ if (typeof value?.error?.message === 'string') return value.error.message;
10
+ if (typeof value?.message === 'string') return value.message;
11
+ try {
12
+ return JSON.stringify(value);
13
+ } catch {
14
+ return String(value);
15
+ }
16
+ };
17
+
18
+ const normalizeModelKey = value => {
19
+ if (!value) return '';
20
+ return String(value)
21
+ .toLowerCase()
22
+ .replace(/\[1m\]$/i, '')
23
+ .trim();
24
+ };
25
+
26
+ export const classifyRetryableError = value => {
27
+ const message = normalizeMessage(value);
28
+ const lower = message.toLowerCase();
29
+
30
+ if (lower.includes('selected model is at capacity') || (lower.includes('at capacity') && lower.includes('try a different model'))) {
31
+ return { message, isRetryable: true, isCapacity: true, label: 'Model capacity error' };
32
+ }
33
+
34
+ if (lower.includes('overloaded') || lower.includes('overloaded_error')) {
35
+ return { message, isRetryable: true, isCapacity: true, label: 'API overload' };
36
+ }
37
+
38
+ if (lower.includes('request timed out')) {
39
+ return { message, isRetryable: true, isCapacity: false, label: 'Request timeout' };
40
+ }
41
+
42
+ if (lower.includes('api error: 503') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
43
+ return { message, isRetryable: true, isCapacity: false, label: '503 network error' };
44
+ }
45
+
46
+ if (lower.includes('internal server error') || lower.includes('api error: 500')) {
47
+ return { message, isRetryable: true, isCapacity: false, label: 'Internal server error (500)' };
48
+ }
49
+
50
+ return { message, isRetryable: false, isCapacity: false, label: null };
51
+ };
52
+
53
+ export const getRetryDelayMs = ({ retryCount, initialDelayMs = retryLimits.initialTransientErrorDelayMs, maxDelayMs = retryLimits.maxTransientErrorDelayMs } = {}) => {
54
+ return Math.min(initialDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount), maxDelayMs);
55
+ };
56
+
57
+ export const waitWithCountdown = async (delayMs, log) => {
58
+ if (delayMs <= 60000) {
59
+ await new Promise(resolve => setTimeout(resolve, delayMs));
60
+ return;
61
+ }
62
+
63
+ let remaining = delayMs;
64
+ const timer = setInterval(async () => {
65
+ remaining -= 60000;
66
+ if (remaining > 0) await log(`⏳ ${Math.round(remaining / 60000)} min remaining...`);
67
+ }, 60000);
68
+
69
+ await new Promise(resolve => setTimeout(resolve, delayMs));
70
+ clearInterval(timer);
71
+ };
72
+
73
+ export const resolveConfiguredFallbackModel = ({ tool, currentModel, configuredFallbackModel = undefined } = {}) => {
74
+ if (configuredFallbackModel) return configuredFallbackModel;
75
+ return resolveDefaultFallbackModel(tool, currentModel);
76
+ };
77
+
78
+ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage } = {}) => {
79
+ const fallbackModel = resolveConfiguredFallbackModel({
80
+ tool,
81
+ currentModel: argv?.model,
82
+ configuredFallbackModel: argv?.fallbackModel,
83
+ });
84
+
85
+ const classification = classifyRetryableError(errorMessage);
86
+ if (!fallbackModel || !classification.isCapacity || !argv?.model) {
87
+ return { switched: false, fallbackModel, reason: classification.label };
88
+ }
89
+
90
+ const currentResolvedModel = normalizeModelKey(resolveModelId(argv.model, tool));
91
+ const fallbackResolvedModel = normalizeModelKey(resolveModelId(fallbackModel, tool));
92
+ if (!fallbackResolvedModel || currentResolvedModel === fallbackResolvedModel) {
93
+ return { switched: false, fallbackModel, reason: classification.label };
94
+ }
95
+
96
+ const previousModel = argv.model;
97
+ argv.model = fallbackModel;
98
+ if (!argv.fallbackModel) argv.fallbackModel = fallbackModel;
99
+
100
+ if (typeof log === 'function') {
101
+ await log(`🔀 Switching to fallback model: ${previousModel} -> ${fallbackModel}`, { level: 'warning' });
102
+ }
103
+
104
+ return {
105
+ switched: true,
106
+ fallbackModel,
107
+ previousModel,
108
+ reason: classification.label,
109
+ };
110
+ };
111
+
112
+ export default {
113
+ classifyRetryableError,
114
+ getRetryDelayMs,
115
+ waitWithCountdown,
116
+ resolveConfiguredFallbackModel,
117
+ maybeSwitchToFallbackModel,
118
+ };