@link-assistant/hive-mind 1.31.4 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.32.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2f710dd: fix: sanitize orphaned UTF-16 surrogates across all CLI output parsing paths (Issue #1324)
8
+
9
+ Extract `sanitizeUnicode()` and `sanitizeObjectStrings()` into a shared `unicode-sanitization.lib.mjs` module and apply sanitization in all CLI output parsing paths — `claude.lib.mjs`, `agent.lib.mjs`, `codex.lib.mjs`, `opencode.lib.mjs`, and `interactive-mode.lib.mjs`. This ensures orphaned UTF-16 surrogates (from Claude CLI's `<persisted-output>` truncation) are replaced with U+FFFD before any JSON re-serialization, logging, or API calls. Add 62 unit tests covering surrogate edge cases, real-world Claude NDJSON events, and JSON round-trip safety.
10
+
11
+ ## 1.32.0
12
+
13
+ ### Minor Changes
14
+
15
+ - b2c94db: Support all options via /solve command when replying to a message containing a GitHub link (issue #1325)
16
+
17
+ Previously, `/solve` as a reply only worked when used without any arguments. Now users can reply to a message containing a GitHub issue/PR link with `/solve --model opus` or any other options, and the bot will:
18
+ 1. Extract the GitHub URL from the replied message
19
+ 2. Use the provided options
20
+ 3. Execute the solve command with both the extracted URL and the user-provided options
21
+
3
22
  ## 1.31.4
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.31.4",
3
+ "version": "1.32.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/agent.lib.mjs CHANGED
@@ -17,6 +17,7 @@ import { log } from './lib.mjs';
17
17
  import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
21
 
21
22
  // Import pricing functions from claude.lib.mjs
22
23
  // We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
@@ -47,7 +48,7 @@ export const parseAgentTokenUsage = output => {
47
48
  if (!trimmedLine || !trimmedLine.startsWith('{')) continue;
48
49
 
49
50
  try {
50
- const parsed = JSON.parse(trimmedLine);
51
+ const parsed = sanitizeObjectStrings(JSON.parse(trimmedLine));
51
52
 
52
53
  // Look for step_finish events which contain token usage
53
54
  if (parsed.type === 'step_finish' && parsed.part?.tokens) {
@@ -615,7 +616,7 @@ export const executeAgentCommand = async params => {
615
616
  for (const line of lines) {
616
617
  if (!line.trim()) continue;
617
618
  try {
618
- const data = JSON.parse(line);
619
+ const data = sanitizeObjectStrings(JSON.parse(line));
619
620
  // Output formatted JSON
620
621
  await log(JSON.stringify(data, null, 2));
621
622
  // Capture session ID from the first message
@@ -689,7 +690,7 @@ export const executeAgentCommand = async params => {
689
690
  for (const stderrLine of stderrLines) {
690
691
  if (!stderrLine.trim()) continue;
691
692
  try {
692
- const stderrData = JSON.parse(stderrLine);
693
+ const stderrData = sanitizeObjectStrings(JSON.parse(stderrLine));
693
694
  // Output formatted JSON (same formatting as stdout)
694
695
  await log(JSON.stringify(stderrData, null, 2));
695
696
  // Capture session ID from stderr too (agent sends it via stderr)
@@ -767,7 +768,7 @@ export const executeAgentCommand = async params => {
767
768
  if (!line.trim()) continue;
768
769
 
769
770
  try {
770
- const msg = JSON.parse(line);
771
+ const msg = sanitizeObjectStrings(JSON.parse(line));
771
772
 
772
773
  // Check for explicit error message types from agent
773
774
  if (msg.type === 'error' || msg.type === 'step_error') {
@@ -12,6 +12,7 @@ import { reportError } from './sentry.lib.mjs';
12
12
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
13
13
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
14
14
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
15
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
15
16
  import { displayBudgetStats } from './claude.budget-stats.lib.mjs';
16
17
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
17
18
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
@@ -974,7 +975,7 @@ export const executeClaudeCommand = async params => {
974
975
  for (const line of lines) {
975
976
  if (!line.trim()) continue;
976
977
  try {
977
- const data = JSON.parse(line);
978
+ const data = sanitizeObjectStrings(JSON.parse(line));
978
979
  // Process event in interactive mode
979
980
  if (interactiveHandler) {
980
981
  try {
@@ -1153,7 +1154,7 @@ export const executeClaudeCommand = async params => {
1153
1154
  // Issue #1183: Process remaining buffer content - extract cost from result type if present
1154
1155
  if (stdoutLineBuffer.trim()) {
1155
1156
  try {
1156
- const data = JSON.parse(stdoutLineBuffer);
1157
+ const data = sanitizeObjectStrings(JSON.parse(stdoutLineBuffer));
1157
1158
  await log(JSON.stringify(data, null, 2));
1158
1159
  if (data.type === 'result' && data.subtype === 'success' && data.total_cost_usd != null) {
1159
1160
  anthropicTotalCostUSD = data.total_cost_usd;
package/src/codex.lib.mjs CHANGED
@@ -17,6 +17,7 @@ import { log } from './lib.mjs';
17
17
  import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
21
 
21
22
  // Model mapping to translate aliases to full model IDs for Codex
22
23
  export const mapModelToId = model => {
@@ -303,7 +304,7 @@ export const executeCodexCommand = async params => {
303
304
  const lines = output.split('\n');
304
305
  for (const line of lines) {
305
306
  if (!line.trim()) continue;
306
- const data = JSON.parse(line);
307
+ const data = sanitizeObjectStrings(JSON.parse(line));
307
308
  // Check for both thread_id (codex) and session_id (legacy)
308
309
  if ((data.thread_id || data.session_id) && !sessionId) {
309
310
  sessionId = data.thread_id || data.session_id;
@@ -516,9 +516,7 @@ export async function checkMergePermissions(owner, repo, verbose = false) {
516
516
  * @param {string} repo - Repository name
517
517
  * @param {number} prNumber - Pull request number
518
518
  * @param {Object} options - Merge options
519
- * @param {string} options.mergeMethod - Merge method: 'merge', 'squash', or 'rebase' (default: 'merge')
520
- * Note: Must specify one method when running non-interactively.
521
- * See Issue #1269 for details.
519
+ * @param {string} options.mergeMethod - Merge method: 'merge', 'squash', or 'rebase' (default: 'merge'). Must specify one method non-interactively (Issue #1269).
522
520
  * @param {boolean} options.squash - DEPRECATED: Use mergeMethod: 'squash' instead
523
521
  * @param {boolean} options.deleteAfter - Whether to delete branch after merge (default: false)
524
522
  * @param {boolean} verbose - Whether to log verbose output
@@ -42,16 +42,26 @@ const CONFIG = {
42
42
  MAX_JSON_DEPTH: 10,
43
43
  };
44
44
 
45
+ // Import sanitizeUnicode from the shared module so that the same logic is used
46
+ // everywhere: in the interactive-mode PR-comment path and in the regular
47
+ // Claude output parsing path (claude.lib.mjs).
48
+ // See: https://github.com/link-assistant/hive-mind/issues/1324
49
+ import { sanitizeUnicode } from './unicode-sanitization.lib.mjs';
50
+
45
51
  /**
46
52
  * Truncate content in the middle, keeping start and end
47
53
  * This helps show context while reducing size for large outputs
48
54
  *
55
+ * The result is always passed through sanitizeUnicode() so that a truncation
56
+ * point that falls inside a UTF-16 surrogate pair never produces invalid JSON.
57
+ * See: https://github.com/link-assistant/hive-mind/issues/1324
58
+ *
49
59
  * @param {string} content - Content to potentially truncate
50
60
  * @param {Object} options - Truncation options
51
61
  * @param {number} [options.maxLines=50] - Maximum lines before truncation
52
62
  * @param {number} [options.keepStart=20] - Lines to keep at start
53
63
  * @param {number} [options.keepEnd=20] - Lines to keep at end
54
- * @returns {string} Truncated content with ellipsis indicator
64
+ * @returns {string} Truncated, Unicode-sanitized content with ellipsis indicator
55
65
  */
56
66
  const truncateMiddle = (content, options = {}) => {
57
67
  const { maxLines = CONFIG.MAX_LINES_BEFORE_TRUNCATION, keepStart = CONFIG.LINES_TO_KEEP_START, keepEnd = CONFIG.LINES_TO_KEEP_END } = options;
@@ -62,22 +72,27 @@ const truncateMiddle = (content, options = {}) => {
62
72
 
63
73
  const lines = content.split('\n');
64
74
  if (lines.length <= maxLines) {
65
- return content;
75
+ return sanitizeUnicode(content);
66
76
  }
67
77
 
68
78
  const startLines = lines.slice(0, keepStart);
69
79
  const endLines = lines.slice(-keepEnd);
70
80
  const removedCount = lines.length - keepStart - keepEnd;
71
81
 
72
- return [...startLines, '', `... [${removedCount} lines truncated] ...`, '', ...endLines].join('\n');
82
+ return sanitizeUnicode([...startLines, '', `... [${removedCount} lines truncated] ...`, '', ...endLines].join('\n'));
73
83
  };
74
84
 
75
85
  /**
76
- * Safely stringify JSON with depth limit and circular reference handling
86
+ * Safely stringify JSON with depth limit and circular reference handling.
87
+ * String values are passed through sanitizeUnicode() so that orphaned UTF-16
88
+ * surrogates (which can appear after persisted-output truncation) never reach
89
+ * JSON.stringify() and cause a 400 API error.
90
+ *
91
+ * @see https://github.com/link-assistant/hive-mind/issues/1324
77
92
  *
78
93
  * @param {any} obj - Object to stringify
79
94
  * @param {number} [indent=2] - Indentation spaces
80
- * @returns {string} Formatted JSON string
95
+ * @returns {string} Formatted JSON string with sanitized Unicode
81
96
  */
82
97
  const safeJsonStringify = (obj, indent = 2) => {
83
98
  const seen = new WeakSet();
@@ -90,6 +105,9 @@ const safeJsonStringify = (obj, indent = 2) => {
90
105
  }
91
106
  seen.add(value);
92
107
  }
108
+ if (typeof value === 'string') {
109
+ return sanitizeUnicode(value);
110
+ }
93
111
  return value;
94
112
  },
95
113
  indent
@@ -954,6 +972,7 @@ export const validateInteractiveModeConfig = async (argv, log) => {
954
972
 
955
973
  // Export utilities for testing
956
974
  export const utils = {
975
+ sanitizeUnicode,
957
976
  truncateMiddle,
958
977
  safeJsonStringify,
959
978
  createCollapsible,
@@ -17,6 +17,7 @@ import { log } from './lib.mjs';
17
17
  import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
21
 
21
22
  // Model mapping to translate aliases to full model IDs for OpenCode
22
23
  export const mapModelToId = model => {
@@ -322,7 +323,7 @@ export const executeOpenCodeCommand = async params => {
322
323
  const lines = output.split('\n');
323
324
  for (const line of lines) {
324
325
  if (!line.trim()) continue;
325
- const data = JSON.parse(line);
326
+ const data = sanitizeObjectStrings(JSON.parse(line));
326
327
  // Track text content for result summary
327
328
  // OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
328
329
  if (data.type === 'text' && data.text) {
@@ -364,7 +365,7 @@ export const executeOpenCodeCommand = async params => {
364
365
  const lines = errorOutput.split('\n');
365
366
  for (const line of lines) {
366
367
  if (!line.trim()) continue;
367
- const data = JSON.parse(line);
368
+ const data = sanitizeObjectStrings(JSON.parse(line));
368
369
  if (data.type === 'text' && data.text) {
369
370
  lastTextContent = data.text;
370
371
  } else if (data.type === 'assistant' && data.message?.content) {
package/src/solve.mjs CHANGED
@@ -98,12 +98,10 @@ const { validateAndExitOnInvalidModel } = modelValidation;
98
98
  const acceptInviteLib = await import('./solve.accept-invite.lib.mjs');
99
99
  const { autoAcceptInviteForRepo } = acceptInviteLib;
100
100
 
101
- // Initialize log file EARLY to capture all output including version and command
102
- // Use default directory (cwd) initially, will be set from argv.logDir after parsing
101
+ // Initialize log file EARLY (use cwd initially, will be updated after argv parsing)
103
102
  const logFile = await initializeLogFile(null);
104
103
 
105
- // Log version and raw command IMMEDIATELY after log file initialization
106
- // This ensures they appear in both console and log file, even if argument parsing fails
104
+ // Log version and raw command IMMEDIATELY after log file initialization (ensures they appear even if parsing fails)
107
105
  const versionInfo = await getVersionInfo();
108
106
  await log('');
109
107
  await log(`🚀 solve v${versionInfo}`);
@@ -221,9 +219,7 @@ if (!(await validateContinueOnlyOnFeedback(argv, isPrUrl, isIssueUrl))) {
221
219
  const tool = argv.tool || 'claude';
222
220
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
223
221
 
224
- // Perform all system checks using validation module
225
- // Skip tool CONNECTION validation in dry-run mode or when --skip-tool-connection-check or --no-tool-connection-check is enabled
226
- // Note: This does NOT skip model validation which is performed above
222
+ // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
227
223
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
228
224
  if (!(await performSystemChecks(argv.minDiskSpace || 2048, skipToolConnectionCheck, argv.model, argv))) {
229
225
  await safeExit(1, 'System checks failed');
@@ -236,9 +232,7 @@ if (argv.verbose) {
236
232
  await log(` Is PR URL: ${!!isPrUrl}`, { verbose: true });
237
233
  }
238
234
  const claudePath = argv.executeToolWithBun ? 'bunx claude' : process.env.CLAUDE_PATH || 'claude';
239
- // Note: owner, repo, and urlNumber are already extracted from validateGitHubUrl() above
240
- // The parseUrlComponents() call was removed as it had a bug with hash fragments (#issuecomment-xyz)
241
- // and the validation result already provides these values correctly parsed
235
+ // Note: owner, repo, and urlNumber are extracted from validateGitHubUrl() above (parseUrlComponents() removed due to hash fragment bug)
242
236
 
243
237
  // Handle --auto-fork option: automatically fork public repositories without write access
244
238
  if (argv.autoFork && !argv.fork) {
@@ -52,7 +52,7 @@ const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mj
52
52
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
53
53
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
54
54
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
55
- const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText } = await import('./telegram-message-filters.lib.mjs');
55
+ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
56
56
  // Import bot launcher with exponential backoff retry (issue #1240)
57
57
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
58
58
 
@@ -313,10 +313,6 @@ function isOldMessage(ctx) {
313
313
  return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
314
314
  }
315
315
 
316
- function isGroupChat(ctx) {
317
- return _isGroupChat(ctx);
318
- }
319
-
320
316
  function isForwardedOrReply(ctx) {
321
317
  return _isForwardedOrReply(ctx, { verbose: VERBOSE });
322
318
  }
@@ -596,46 +592,6 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
596
592
  }
597
593
  }
598
594
 
599
- /**
600
- * Extract GitHub issue/PR URL from message text
601
- * Validates that message contains exactly one GitHub issue/PR link
602
- *
603
- * @param {string} text - Message text to search
604
- * @returns {{ url: string|null, error: string|null, linkCount: number }}
605
- */
606
- function extractGitHubUrl(text) {
607
- if (!text || typeof text !== 'string') {
608
- return { url: null, error: null, linkCount: 0 };
609
- }
610
-
611
- text = cleanNonPrintableChars(text); // Clean non-printable chars before processing
612
- const words = text.split(/\s+/);
613
- const foundUrls = [];
614
-
615
- for (const word of words) {
616
- // Try to parse as GitHub URL
617
- const parsed = parseGitHubUrl(word);
618
-
619
- // Accept issue or PR URLs
620
- if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
621
- foundUrls.push(parsed.normalized);
622
- }
623
- }
624
-
625
- // Check if multiple links were found
626
- if (foundUrls.length === 0) {
627
- return { url: null, error: null, linkCount: 0 };
628
- } else if (foundUrls.length === 1) {
629
- return { url: foundUrls[0], error: null, linkCount: 1 };
630
- } else {
631
- return {
632
- url: null,
633
- error: `Found ${foundUrls.length} GitHub links in the message. Please reply to a message with only one GitHub issue or PR link.`,
634
- linkCount: foundUrls.length,
635
- };
636
- }
637
- }
638
-
639
595
  bot.command('help', async ctx => {
640
596
  if (VERBOSE) {
641
597
  console.log('[VERBOSE] /help command received');
@@ -760,7 +716,7 @@ bot.command('limits', async ctx => {
760
716
  return;
761
717
  }
762
718
 
763
- if (!isGroupChat(ctx)) {
719
+ if (!_isGroupChat(ctx)) {
764
720
  if (VERBOSE) {
765
721
  console.log('[VERBOSE] /limits ignored: not a group chat');
766
722
  }
@@ -809,7 +765,7 @@ bot.command('version', async ctx => {
809
765
  data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
810
766
  });
811
767
  if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
812
- if (!isGroupChat(ctx)) return await ctx.reply('❌ The /version command only works in group chats. Please add this bot to a group and make it an admin.', { reply_to_message_id: ctx.message.message_id });
768
+ if (!_isGroupChat(ctx)) return await ctx.reply('❌ The /version command only works in group chats. Please add this bot to a group and make it an admin.', { reply_to_message_id: ctx.message.message_id });
813
769
  const chatId = ctx.chat.id;
814
770
  if (!isChatAuthorized(chatId)) return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
815
771
  const fetchingMessage = await ctx.reply('🔄 Gathering version information...', {
@@ -827,7 +783,7 @@ registerAcceptInvitesCommand(bot, {
827
783
  VERBOSE,
828
784
  isOldMessage,
829
785
  isForwardedOrReply,
830
- isGroupChat,
786
+ isGroupChat: _isGroupChat,
831
787
  isChatAuthorized,
832
788
  addBreadcrumb,
833
789
  });
@@ -838,7 +794,7 @@ registerMergeCommand(bot, {
838
794
  VERBOSE,
839
795
  isOldMessage,
840
796
  isForwardedOrReply,
841
- isGroupChat,
797
+ isGroupChat: _isGroupChat,
842
798
  isChatAuthorized,
843
799
  addBreadcrumb,
844
800
  });
@@ -849,7 +805,7 @@ const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, {
849
805
  VERBOSE,
850
806
  isOldMessage,
851
807
  isForwardedOrReply,
852
- isGroupChat,
808
+ isGroupChat: _isGroupChat,
853
809
  isChatAuthorized,
854
810
  addBreadcrumb,
855
811
  getSolveQueue,
@@ -903,7 +859,7 @@ async function handleSolveCommand(ctx) {
903
859
  return;
904
860
  }
905
861
 
906
- if (!isGroupChat(ctx)) {
862
+ if (!_isGroupChat(ctx)) {
907
863
  if (VERBOSE) {
908
864
  console.log('[VERBOSE] /solve ignored: not a group chat');
909
865
  }
@@ -926,17 +882,23 @@ async function handleSolveCommand(ctx) {
926
882
 
927
883
  let userArgs = parseCommandArgs(ctx.message.text);
928
884
 
929
- // Check if this is a reply to a message and user didn't provide URL
885
+ // Check if this is a reply to a message and user didn't provide URL as first argument
930
886
  // In that case, try to extract GitHub URL from the replied message
887
+ // Issue #1325: Support all options via /solve command when replying (e.g., "/solve --model opus")
931
888
  const isReply = message.reply_to_message && message.reply_to_message.message_id && !message.reply_to_message.forum_topic_created;
932
889
 
933
- if (isReply && userArgs.length === 0) {
890
+ // Check if the first argument looks like a GitHub URL
891
+ // If not, we should try to extract the URL from the replied message
892
+ const firstArgIsUrl = userArgs.length > 0 && (userArgs[0].includes('github.com') || userArgs[0].match(/^https?:\/\//));
893
+
894
+ if (isReply && !firstArgIsUrl) {
934
895
  if (VERBOSE) {
935
- console.log('[VERBOSE] /solve is a reply without URL, extracting from replied message...');
896
+ console.log('[VERBOSE] /solve is a reply without URL in args, extracting from replied message...');
897
+ console.log('[VERBOSE] User args:', userArgs);
936
898
  }
937
899
 
938
900
  const replyText = message.reply_to_message.text || '';
939
- const extraction = extractGitHubUrl(replyText);
901
+ const extraction = _extractGitHubUrl(replyText, { parseGitHubUrl, cleanNonPrintableChars });
940
902
 
941
903
  if (extraction.error) {
942
904
  // Multiple links found
@@ -949,18 +911,18 @@ async function handleSolveCommand(ctx) {
949
911
  });
950
912
  return;
951
913
  } else if (extraction.url) {
952
- // Single link found
914
+ // Single link found - prepend it to existing user args (issue #1325)
953
915
  if (VERBOSE) {
954
916
  console.log('[VERBOSE] Extracted URL from reply:', extraction.url);
955
917
  }
956
- // Add the extracted URL as the first argument
957
- userArgs = [extraction.url];
918
+ // Prepend the extracted URL to user's options (e.g., ['--model', 'opus'] -> ['url', '--model', 'opus'])
919
+ userArgs = [extraction.url, ...userArgs];
958
920
  } else {
959
921
  // No link found
960
922
  if (VERBOSE) {
961
923
  console.log('[VERBOSE] No GitHub URL found in replied message');
962
924
  }
963
- await ctx.reply('❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`', { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
925
+ await ctx.reply('❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`\n\nOr with options: `/solve --model opus`', { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
964
926
  return;
965
927
  }
966
928
  }
@@ -1113,7 +1075,7 @@ async function handleHiveCommand(ctx) {
1113
1075
  return;
1114
1076
  }
1115
1077
 
1116
- if (!isGroupChat(ctx)) {
1078
+ if (!_isGroupChat(ctx)) {
1117
1079
  if (VERBOSE) {
1118
1080
  console.log('[VERBOSE] /hive ignored: not a group chat');
1119
1081
  }
@@ -1217,7 +1179,7 @@ registerTopCommand(bot, {
1217
1179
  VERBOSE,
1218
1180
  isOldMessage,
1219
1181
  isForwardedOrReply,
1220
- isGroupChat,
1182
+ isGroupChat: _isGroupChat,
1221
1183
  isChatAuthorized,
1222
1184
  });
1223
1185
 
@@ -171,3 +171,48 @@ export function extractCommandFromText(text, botUsername = null) {
171
171
 
172
172
  return { command, botMention };
173
173
  }
174
+
175
+ /**
176
+ * Extract GitHub issue/PR URL from message text.
177
+ * Validates that message contains exactly one GitHub issue/PR link.
178
+ * Extracted from telegram-bot.mjs to reduce file size (issue #1325).
179
+ *
180
+ * @param {string} text - Message text to search
181
+ * @param {Object} deps - Dependencies for parsing
182
+ * @param {Function} deps.parseGitHubUrl - Function to parse GitHub URLs
183
+ * @param {Function} deps.cleanNonPrintableChars - Function to clean non-printable characters
184
+ * @returns {{ url: string|null, error: string|null, linkCount: number }}
185
+ * @see https://github.com/link-assistant/hive-mind/issues/1325
186
+ */
187
+ export function extractGitHubUrl(text, { parseGitHubUrl, cleanNonPrintableChars }) {
188
+ if (!text || typeof text !== 'string') {
189
+ return { url: null, error: null, linkCount: 0 };
190
+ }
191
+
192
+ text = cleanNonPrintableChars(text); // Clean non-printable chars before processing
193
+ const words = text.split(/\s+/);
194
+ const foundUrls = [];
195
+
196
+ for (const word of words) {
197
+ // Try to parse as GitHub URL
198
+ const parsed = parseGitHubUrl(word);
199
+
200
+ // Accept issue or PR URLs
201
+ if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
202
+ foundUrls.push(parsed.normalized);
203
+ }
204
+ }
205
+
206
+ // Check if multiple links were found
207
+ if (foundUrls.length === 0) {
208
+ return { url: null, error: null, linkCount: 0 };
209
+ } else if (foundUrls.length === 1) {
210
+ return { url: foundUrls[0], error: null, linkCount: 1 };
211
+ } else {
212
+ return {
213
+ url: null,
214
+ error: `Found ${foundUrls.length} GitHub links in the message. Please reply to a message with only one GitHub issue or PR link.`,
215
+ linkCount: foundUrls.length,
216
+ };
217
+ }
218
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Unicode Sanitization Utility
3
+ *
4
+ * Provides functions to sanitize orphaned UTF-16 surrogates from strings.
5
+ * When Claude Code's <persisted-output> truncation splits a surrogate pair,
6
+ * the orphaned high surrogate (e.g. \uD83E without \uDD16) causes
7
+ * JSON.stringify() to produce invalid JSON that the Anthropic API rejects:
8
+ *
9
+ * API Error: 400 {"type":"error","error":{"type":"invalid_request_error",
10
+ * "message":"The request body is not valid JSON: no low surrogate in string..."}}
11
+ *
12
+ * This module is used by both the regular Claude output parsing path
13
+ * (claude.lib.mjs) and the interactive mode PR comment path
14
+ * (interactive-mode.lib.mjs) to ensure all text is valid before
15
+ * JSON serialization or external API calls.
16
+ *
17
+ * @see https://github.com/link-assistant/hive-mind/issues/1324
18
+ * @see https://www.rfc-editor.org/rfc/rfc8259#section-7
19
+ * @module unicode-sanitization
20
+ */
21
+
22
+ /**
23
+ * Replace every orphaned UTF-16 surrogate with the Unicode replacement
24
+ * character U+FFFD. A "well-formed" string never contains:
25
+ * - A high surrogate (U+D800–U+DBFF) not immediately followed by a low surrogate (U+DC00–U+DFFF)
26
+ * - A low surrogate (U+DC00–U+DFFF) not immediately preceded by a high surrogate
27
+ *
28
+ * @param {string} text - Input string that may contain orphaned surrogates
29
+ * @returns {string} String with every orphaned surrogate replaced by U+FFFD
30
+ */
31
+ export const sanitizeUnicode = text => {
32
+ if (!text || typeof text !== 'string') {
33
+ return text || '';
34
+ }
35
+ // Regex explanation:
36
+ // [\uD800-\uDBFF](?![\uDC00-\uDFFF]) — high surrogate not followed by low surrogate
37
+ // |
38
+ // (?<![\uD800-\uDBFF])[\uDC00-\uDFFF] — low surrogate not preceded by high surrogate
39
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
40
+ };
41
+
42
+ /**
43
+ * Recursively sanitize all string values in an object/array.
44
+ * This is useful for sanitizing parsed JSON objects from Claude CLI output
45
+ * before they are re-serialized or processed.
46
+ *
47
+ * @param {any} value - Value to sanitize (strings are sanitized, objects/arrays are traversed)
48
+ * @returns {any} The value with all string leaves sanitized
49
+ */
50
+ export const sanitizeObjectStrings = value => {
51
+ if (typeof value === 'string') {
52
+ return sanitizeUnicode(value);
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return value.map(sanitizeObjectStrings);
56
+ }
57
+ if (typeof value === 'object' && value !== null) {
58
+ const result = {};
59
+ for (const [key, val] of Object.entries(value)) {
60
+ result[key] = sanitizeObjectStrings(val);
61
+ }
62
+ return result;
63
+ }
64
+ return value;
65
+ };
66
+
67
+ export default { sanitizeUnicode, sanitizeObjectStrings };