@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 +19 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -4
- package/src/claude.lib.mjs +3 -2
- package/src/codex.lib.mjs +2 -1
- package/src/github-merge.lib.mjs +1 -3
- package/src/interactive-mode.lib.mjs +24 -5
- package/src/opencode.lib.mjs +3 -2
- package/src/solve.mjs +4 -10
- package/src/telegram-bot.mjs +23 -61
- package/src/telegram-message-filters.lib.mjs +45 -0
- package/src/unicode-sanitization.lib.mjs +67 -0
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
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') {
|
package/src/claude.lib.mjs
CHANGED
|
@@ -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;
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -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,
|
package/src/opencode.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 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
|
|
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
|
|
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
|
|
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) {
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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 (!
|
|
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 };
|