@link-assistant/hive-mind 1.74.0 → 1.74.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.hi.md +30 -2
- package/README.md +33 -2
- package/README.ru.md +32 -1
- package/README.zh.md +30 -2
- package/package.json +1 -1
- package/src/claude.lib.mjs +2 -0
- package/src/cleanup.mjs +75 -1
- package/src/cleanup.os.lib.mjs +341 -0
- package/src/codex.lib.mjs +2 -0
- package/src/interactive-image-render.lib.mjs +140 -0
- package/src/interactive-image-upload.lib.mjs +415 -0
- package/src/interactive-mode.lib.mjs +27 -8
- package/src/interactive-mode.shared.lib.mjs +97 -0
- package/src/isolation-runner.lib.mjs +27 -4
- package/src/process-debug.lib.mjs +361 -0
- package/src/solve.config.lib.mjs +9 -0
package/src/cleanup.os.lib.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import os from 'node:os';
|
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
21
|
|
|
22
22
|
import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
23
|
+
import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
|
|
23
24
|
|
|
24
25
|
/** Run a command, returning trimmed stdout or null on any failure. */
|
|
25
26
|
function tryExec(cmd, args, options = {}) {
|
|
@@ -191,6 +192,346 @@ export function listProcessHeldPaths(tempRoot) {
|
|
|
191
192
|
return held;
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
function parseProcStat(raw) {
|
|
196
|
+
if (!raw) return null;
|
|
197
|
+
const open = raw.indexOf('(');
|
|
198
|
+
const close = raw.lastIndexOf(')');
|
|
199
|
+
if (open < 0 || close < open) return null;
|
|
200
|
+
const commandName = raw.slice(open + 1, close);
|
|
201
|
+
const fields = raw
|
|
202
|
+
.slice(close + 2)
|
|
203
|
+
.trim()
|
|
204
|
+
.split(/\s+/);
|
|
205
|
+
return {
|
|
206
|
+
commandName,
|
|
207
|
+
state: fields[0] || null,
|
|
208
|
+
ppid: Number(fields[1]) || 0,
|
|
209
|
+
pgid: Number(fields[2]) || null,
|
|
210
|
+
sid: Number(fields[3]) || null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readProcText(filePath) {
|
|
215
|
+
try {
|
|
216
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readProcLink(filePath) {
|
|
223
|
+
try {
|
|
224
|
+
return fs.readlinkSync(filePath);
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readProcCmdline(pid, fallbackName) {
|
|
231
|
+
const raw = readProcText(`/proc/${pid}/cmdline`);
|
|
232
|
+
const cmdline = raw ? raw.replace(/\0/g, ' ').trim() : '';
|
|
233
|
+
return cmdline || fallbackName || '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readProcScreenSessionName(pid) {
|
|
237
|
+
const raw = readProcText(`/proc/${pid}/environ`);
|
|
238
|
+
if (!raw) return null;
|
|
239
|
+
const sty = raw
|
|
240
|
+
.split('\0')
|
|
241
|
+
.find(line => line.startsWith('STY='))
|
|
242
|
+
?.slice(4)
|
|
243
|
+
?.trim();
|
|
244
|
+
if (!sty) return null;
|
|
245
|
+
return sty.replace(/^\d+\./, '') || sty;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Snapshot Linux process records used by process diagnostics and orphan
|
|
250
|
+
* cleanup. Returns an empty list when procfs is unavailable.
|
|
251
|
+
*
|
|
252
|
+
* @returns {Array<{pid: number, ppid: number, pgid: number|null, sid: number|null, state: string|null, commandName: string|null, cmdline: string, cwd: string|null, exe: string|null, screenSessionName: string|null}>}
|
|
253
|
+
*/
|
|
254
|
+
export function listProcessRecords() {
|
|
255
|
+
let pids;
|
|
256
|
+
try {
|
|
257
|
+
pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const records = [];
|
|
263
|
+
for (const pidText of pids) {
|
|
264
|
+
const pid = Number(pidText);
|
|
265
|
+
const stat = parseProcStat(readProcText(`/proc/${pid}/stat`));
|
|
266
|
+
if (!stat) continue;
|
|
267
|
+
records.push({
|
|
268
|
+
pid,
|
|
269
|
+
ppid: stat.ppid,
|
|
270
|
+
pgid: stat.pgid,
|
|
271
|
+
sid: stat.sid,
|
|
272
|
+
state: stat.state,
|
|
273
|
+
commandName: stat.commandName,
|
|
274
|
+
cmdline: readProcCmdline(pid, stat.commandName),
|
|
275
|
+
cwd: readProcLink(`/proc/${pid}/cwd`),
|
|
276
|
+
exe: readProcLink(`/proc/${pid}/exe`),
|
|
277
|
+
screenSessionName: readProcScreenSessionName(pid),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return records;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Discover GNU screen sessions and their backing screen PIDs.
|
|
285
|
+
*
|
|
286
|
+
* @returns {Array<{screenPid: number, sessionName: string, displayName: string, attached: boolean, live: boolean}>}
|
|
287
|
+
*/
|
|
288
|
+
export function listScreenSessions() {
|
|
289
|
+
const out = tryExec('screen', ['-ls']);
|
|
290
|
+
if (!out) return [];
|
|
291
|
+
const sessions = [];
|
|
292
|
+
for (const line of out.split('\n')) {
|
|
293
|
+
const match = line.match(/^\s*(\d+)\.([^\s]+)\s+\((Attached|Detached)\)/i);
|
|
294
|
+
if (!match) continue;
|
|
295
|
+
sessions.push({
|
|
296
|
+
screenPid: Number(match[1]),
|
|
297
|
+
sessionName: match[2],
|
|
298
|
+
displayName: `${match[1]}.${match[2]}`,
|
|
299
|
+
attached: match[3].toLowerCase() === 'attached',
|
|
300
|
+
live: true,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return sessions;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function listStartCommandLogFiles(logRoot, maxFiles) {
|
|
307
|
+
const files = [];
|
|
308
|
+
const stack = [logRoot];
|
|
309
|
+
while (stack.length > 0) {
|
|
310
|
+
const current = stack.pop();
|
|
311
|
+
let stat;
|
|
312
|
+
try {
|
|
313
|
+
stat = fs.statSync(current);
|
|
314
|
+
} catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (stat.isDirectory()) {
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = fs.readdirSync(current);
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
for (const entry of entries) stack.push(path.join(current, entry));
|
|
325
|
+
} else if (stat.isFile() && current.endsWith('.log')) {
|
|
326
|
+
files.push({ path: current, mtimeMs: stat.mtimeMs });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return files
|
|
330
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
331
|
+
.slice(0, maxFiles)
|
|
332
|
+
.map(file => file.path);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function readFilePrefix(filePath, maxBytes) {
|
|
336
|
+
let fd;
|
|
337
|
+
try {
|
|
338
|
+
fd = fs.openSync(filePath, 'r');
|
|
339
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
340
|
+
const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
|
|
341
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
342
|
+
} catch {
|
|
343
|
+
return '';
|
|
344
|
+
} finally {
|
|
345
|
+
if (fd !== undefined) {
|
|
346
|
+
try {
|
|
347
|
+
fs.closeSync(fd);
|
|
348
|
+
} catch {
|
|
349
|
+
/* ignore */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mergeSession(map, session) {
|
|
356
|
+
if (!session) return;
|
|
357
|
+
const key = session.sessionId || session.uuid || session.sessionName || session.screenSessionName || session.logPath;
|
|
358
|
+
if (!key) return;
|
|
359
|
+
const existing = map.get(key) || {};
|
|
360
|
+
const mergedProcessIds = { ...(existing.processIds || {}), ...(session.processIds || {}) };
|
|
361
|
+
map.set(key, {
|
|
362
|
+
...existing,
|
|
363
|
+
...session,
|
|
364
|
+
processIds: mergedProcessIds,
|
|
365
|
+
sessionId: session.sessionId || existing.sessionId || session.uuid || existing.uuid || null,
|
|
366
|
+
uuid: session.uuid || existing.uuid || session.sessionId || existing.sessionId || null,
|
|
367
|
+
sessionName: session.sessionName || existing.sessionName || session.screenSessionName || existing.screenSessionName || null,
|
|
368
|
+
screenSessionName: session.screenSessionName || existing.screenSessionName || session.sessionName || existing.sessionName || null,
|
|
369
|
+
live: session.live === true || existing.live === true,
|
|
370
|
+
command: session.command || existing.command || null,
|
|
371
|
+
taskUrl: session.taskUrl || existing.taskUrl || null,
|
|
372
|
+
workspace: session.workspace || existing.workspace || session.workingDirectory || existing.workingDirectory || null,
|
|
373
|
+
logPath: session.logPath || existing.logPath || null,
|
|
374
|
+
tool: session.tool || existing.tool || null,
|
|
375
|
+
status: session.status || existing.status || null,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Collect start-command session/task metadata from logs, live screen sessions,
|
|
381
|
+
* and optional `$ --status` lookups.
|
|
382
|
+
*
|
|
383
|
+
* @param {Object} [options]
|
|
384
|
+
* @param {string} [options.logRoot='/tmp/start-command/logs']
|
|
385
|
+
* @param {number} [options.maxLogFiles=500]
|
|
386
|
+
* @param {number} [options.maxLogBytes=262144]
|
|
387
|
+
* @param {number} [options.maxStatusQueries=200]
|
|
388
|
+
* @param {boolean} [options.useSessions=true]
|
|
389
|
+
* @returns {Promise<Array>}
|
|
390
|
+
*/
|
|
391
|
+
export async function collectProcessDebugSessions(options = {}) {
|
|
392
|
+
const { logRoot = '/tmp/start-command/logs', maxLogFiles = 500, maxLogBytes = 256 * 1024, maxStatusQueries = 200, useSessions = true } = options;
|
|
393
|
+
|
|
394
|
+
const sessions = new Map();
|
|
395
|
+
|
|
396
|
+
for (const logPath of listStartCommandLogFiles(logRoot, maxLogFiles)) {
|
|
397
|
+
const metadata = parseStartCommandLogMetadata({
|
|
398
|
+
logPath,
|
|
399
|
+
text: readFilePrefix(logPath, maxLogBytes),
|
|
400
|
+
});
|
|
401
|
+
mergeSession(sessions, metadata);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const screenSession of listScreenSessions()) {
|
|
405
|
+
mergeSession(sessions, {
|
|
406
|
+
sessionId: screenSession.sessionName,
|
|
407
|
+
sessionName: screenSession.sessionName,
|
|
408
|
+
screenSessionName: screenSession.sessionName,
|
|
409
|
+
processIds: { screenPid: screenSession.screenPid },
|
|
410
|
+
live: true,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!useSessions || sessions.size === 0) return [...sessions.values()];
|
|
415
|
+
|
|
416
|
+
let querySessionStatus;
|
|
417
|
+
try {
|
|
418
|
+
({ querySessionStatus } = await import('./isolation-runner.lib.mjs'));
|
|
419
|
+
} catch {
|
|
420
|
+
return [...sessions.values()];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const queryCandidates = [...sessions.values()].sort((a, b) => (b.live === true) - (a.live === true)).slice(0, maxStatusQueries);
|
|
424
|
+
|
|
425
|
+
for (const session of queryCandidates) {
|
|
426
|
+
const id = session.sessionId || session.uuid || session.sessionName;
|
|
427
|
+
if (!id) continue;
|
|
428
|
+
let status;
|
|
429
|
+
try {
|
|
430
|
+
status = await querySessionStatus(id);
|
|
431
|
+
} catch {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!status?.exists) continue;
|
|
435
|
+
mergeSession(sessions, {
|
|
436
|
+
sessionId: status.uuid || id,
|
|
437
|
+
uuid: status.uuid || id,
|
|
438
|
+
status: status.status || session.status || null,
|
|
439
|
+
command: status.command ? redactProcessText(status.command) : session.command || null,
|
|
440
|
+
taskUrl: status.command ? extractTaskRefsFromCommand(status.command).map(ref => `https://github.com/${ref.owner}/${ref.repo}/${ref.type === 'pull' ? 'pull' : 'issues'}/${ref.number}`)[0] : session.taskUrl || null,
|
|
441
|
+
workspace: status.workingDirectory || session.workspace || null,
|
|
442
|
+
workingDirectory: status.workingDirectory || null,
|
|
443
|
+
logPath: status.logPath || session.logPath || null,
|
|
444
|
+
sessionName: status.sessionName || session.sessionName || id,
|
|
445
|
+
screenSessionName: status.sessionName || session.screenSessionName || session.sessionName || id,
|
|
446
|
+
processIds: status.processIds || {},
|
|
447
|
+
live: session.live === true || status.status === 'executing' || status.status === 'running',
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return [...sessions.values()];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build a redacted process debug report from the real OS state.
|
|
456
|
+
*
|
|
457
|
+
* @param {Object} [options]
|
|
458
|
+
* @returns {Promise<{items: Array, orphans: Array, sessions: Array}>}
|
|
459
|
+
*/
|
|
460
|
+
export async function collectProcessDebugReport(options = {}) {
|
|
461
|
+
const processes = listProcessRecords();
|
|
462
|
+
const sessions = await collectProcessDebugSessions(options);
|
|
463
|
+
const report = correlateProcesses({ processes, sessions, currentPid: process.pid, targetPids: options.targetPids || [] });
|
|
464
|
+
return {
|
|
465
|
+
...report,
|
|
466
|
+
processCount: processes.length,
|
|
467
|
+
sessionCount: sessions.length,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function buildChildrenMap(processes) {
|
|
472
|
+
const children = new Map();
|
|
473
|
+
for (const record of processes || []) {
|
|
474
|
+
if (!record?.pid || !record?.ppid) continue;
|
|
475
|
+
if (!children.has(record.ppid)) children.set(record.ppid, []);
|
|
476
|
+
children.get(record.ppid).push(record.pid);
|
|
477
|
+
}
|
|
478
|
+
return children;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function collectProcessTree(rootPid, children) {
|
|
482
|
+
const seen = new Set();
|
|
483
|
+
const ordered = [];
|
|
484
|
+
const visit = pid => {
|
|
485
|
+
if (!pid || seen.has(pid)) return;
|
|
486
|
+
seen.add(pid);
|
|
487
|
+
for (const child of children.get(pid) || []) visit(child);
|
|
488
|
+
ordered.push(pid);
|
|
489
|
+
};
|
|
490
|
+
visit(rootPid);
|
|
491
|
+
return ordered;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Send a signal to a process tree, children first.
|
|
496
|
+
*
|
|
497
|
+
* @param {number} rootPid
|
|
498
|
+
* @param {Array} processes
|
|
499
|
+
* @param {{signal?: string, currentPid?: number}} [options]
|
|
500
|
+
* @returns {Array<{pid: number, signal: string, ok: boolean, error?: string}>}
|
|
501
|
+
*/
|
|
502
|
+
export function signalProcessTree(rootPid, processes, options = {}) {
|
|
503
|
+
const signal = options.signal || 'SIGTERM';
|
|
504
|
+
const currentPid = options.currentPid || process.pid;
|
|
505
|
+
const children = buildChildrenMap(processes);
|
|
506
|
+
const targets = collectProcessTree(Number(rootPid), children).filter(pid => pid !== currentPid && pid > 1);
|
|
507
|
+
const results = [];
|
|
508
|
+
|
|
509
|
+
for (const pid of targets) {
|
|
510
|
+
try {
|
|
511
|
+
process.kill(pid, signal);
|
|
512
|
+
results.push({ pid, signal, ok: true });
|
|
513
|
+
} catch (error) {
|
|
514
|
+
results.push({ pid, signal, ok: false, error: error.message });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return results;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Signal every orphaned agent tree from a previously collected report.
|
|
522
|
+
*
|
|
523
|
+
* @param {{orphans?: Array}} report
|
|
524
|
+
* @param {Object} [options]
|
|
525
|
+
* @returns {Array<{rootPid: number, results: Array}>}
|
|
526
|
+
*/
|
|
527
|
+
export function signalOrphanedAgentTrees(report, options = {}) {
|
|
528
|
+
const processes = listProcessRecords();
|
|
529
|
+
return (report.orphans || []).map(orphan => ({
|
|
530
|
+
rootPid: orphan.pid,
|
|
531
|
+
results: signalProcessTree(orphan.pid, processes, options),
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
|
|
194
535
|
/**
|
|
195
536
|
* Collect task references (owner/repo/number/type) from running solve/hive
|
|
196
537
|
* processes by scanning /proc/<pid>/cmdline.
|
package/src/codex.lib.mjs
CHANGED
|
@@ -805,6 +805,8 @@ export const executeCodexCommand = async params => {
|
|
|
805
805
|
// comment-posting path can honor them. All default to false.
|
|
806
806
|
skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
|
|
807
807
|
skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
|
|
808
|
+
// Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
|
|
809
|
+
imageUploadEnabled: argv['interactive-image-upload'] !== false,
|
|
808
810
|
});
|
|
809
811
|
} else if (argv.interactiveMode) {
|
|
810
812
|
await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive Mode Image Rendering (Issue #1843)
|
|
4
|
+
*
|
|
5
|
+
* Bridges the raw image uploader (interactive-image-upload.lib.mjs) and the
|
|
6
|
+
* Markdown formatter (interactive-mode.shared.lib.mjs) so interactive-mode.lib.mjs
|
|
7
|
+
* can turn the base64 images Claude/Codex read or wrote into an inline
|
|
8
|
+
* `### 🖼️ Images` section with a single call. Kept in its own module so the main
|
|
9
|
+
* interactive-mode handler stays under the 1500-line file limit (issue #1730).
|
|
10
|
+
*
|
|
11
|
+
* @module interactive-image-render.lib.mjs
|
|
12
|
+
* @experimental
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { formatImageEmbeds } from './interactive-mode.shared.lib.mjs';
|
|
16
|
+
import { createImageUploader, extractImagePayload, isImageNode } from './interactive-image-upload.lib.mjs';
|
|
17
|
+
|
|
18
|
+
// Re-exported so the main handler can import its image helpers from one place.
|
|
19
|
+
export { extractImagePayload, isImageNode };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collect normalized image payloads from tool-result-like objects, de-duplicated
|
|
23
|
+
* by base64 content. Scans arrays directly, an array `content` (Claude/MCP), and
|
|
24
|
+
* the node itself (Claude Read `tool_use_result`). Enriches a missing
|
|
25
|
+
* `originalSize` from a later sibling payload with the same bytes.
|
|
26
|
+
*
|
|
27
|
+
* @param {...*} sources - candidate nodes/containers to scan
|
|
28
|
+
* @returns {Array<{ base64: string, mediaType?: string, originalSize?: number }>}
|
|
29
|
+
*/
|
|
30
|
+
export const collectImagePayloads = (...sources) => {
|
|
31
|
+
const payloads = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const add = node => {
|
|
34
|
+
const payload = extractImagePayload(node);
|
|
35
|
+
if (!payload) return;
|
|
36
|
+
const key = payload.base64.slice(0, 64) + ':' + payload.base64.length;
|
|
37
|
+
if (seen.has(key)) {
|
|
38
|
+
// Enrich an already-collected payload with size metadata if we now have it.
|
|
39
|
+
if (payload.originalSize) {
|
|
40
|
+
const existing = payloads.find(p => p.base64.slice(0, 64) + ':' + p.base64.length === key);
|
|
41
|
+
if (existing && !existing.originalSize) existing.originalSize = payload.originalSize;
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
seen.add(key);
|
|
46
|
+
payloads.push(payload);
|
|
47
|
+
};
|
|
48
|
+
const scan = source => {
|
|
49
|
+
if (!source) return;
|
|
50
|
+
if (Array.isArray(source)) {
|
|
51
|
+
source.forEach(add);
|
|
52
|
+
} else if (typeof source === 'object') {
|
|
53
|
+
if (Array.isArray(source.content)) source.content.forEach(add);
|
|
54
|
+
add(source);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
sources.forEach(scan);
|
|
58
|
+
return payloads;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create an image renderer bound to a PR. Wraps an image uploader (built here, or
|
|
63
|
+
* injected via `options.uploader` for tests) and produces the Markdown image
|
|
64
|
+
* section for a set of tool-result sources. Upload failures / disabled uploads
|
|
65
|
+
* degrade to a metadata note inside `formatImageEmbeds` rather than dumping base64.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [options]
|
|
68
|
+
* @param {Object} [options.uploader] - injected uploader (tests); otherwise built from the remaining options
|
|
69
|
+
* @param {Object} [options.state] - handler state, used to label images by tool name
|
|
70
|
+
* @param {Function} [options.log] - async logging function
|
|
71
|
+
* @param {boolean} [options.verbose=false]
|
|
72
|
+
* @param {string} [options.owner]
|
|
73
|
+
* @param {string} [options.repo]
|
|
74
|
+
* @param {number|string} [options.prNumber]
|
|
75
|
+
* @param {string} [options.mediaRef]
|
|
76
|
+
* @param {string} [options.refNamespace]
|
|
77
|
+
* @param {Function} [options.execFile]
|
|
78
|
+
* @param {boolean} [options.enabled=true]
|
|
79
|
+
* @returns {{ uploader: Object, collect: Function, render: Function, toolLabel: Function, section: Function }}
|
|
80
|
+
*/
|
|
81
|
+
export const createImageRenderer = (options = {}) => {
|
|
82
|
+
const { uploader: injectedUploader, state, log = async () => {}, verbose = false, owner, repo, prNumber, mediaRef, refNamespace, execFile, enabled = true } = options;
|
|
83
|
+
|
|
84
|
+
const uploader = injectedUploader !== undefined ? injectedUploader : createImageUploader({ owner, repo, prNumber, mediaRef, refNamespace, log, verbose, execFile, enabled });
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Upload normalized payloads and render the `### 🖼️ Images` Markdown section.
|
|
88
|
+
* Always returns a string (empty when there are no images).
|
|
89
|
+
* @param {Array<{ base64: string, mediaType?: string, originalSize?: number }>} payloads
|
|
90
|
+
* @param {string} [label] - human label prefix for captions / commit messages
|
|
91
|
+
* @returns {Promise<string>}
|
|
92
|
+
*/
|
|
93
|
+
const render = async (payloads, label = 'image') => {
|
|
94
|
+
if (!Array.isArray(payloads) || payloads.length === 0) return '';
|
|
95
|
+
const rendered = [];
|
|
96
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
97
|
+
const payload = payloads[i];
|
|
98
|
+
const name = payloads.length > 1 ? `${label} ${i + 1}` : label;
|
|
99
|
+
let url = null;
|
|
100
|
+
try {
|
|
101
|
+
if (uploader && typeof uploader.uploadImage === 'function') {
|
|
102
|
+
url = await uploader.uploadImage({ base64: payload.base64, mediaType: payload.mediaType, name });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (verbose) await log(`⚠️ Interactive mode: image upload threw: ${err.message}`, { verbose: true });
|
|
106
|
+
url = null;
|
|
107
|
+
}
|
|
108
|
+
rendered.push({ url, mediaType: payload.mediaType, originalSize: payload.originalSize, name });
|
|
109
|
+
}
|
|
110
|
+
return formatImageEmbeds(rendered);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A human label for image captions, derived from the tool name in the
|
|
115
|
+
* pending-call / registry maps (e.g. "Read image"). Falls back to "image".
|
|
116
|
+
* @param {string} toolUseId
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
const toolLabel = toolUseId => {
|
|
120
|
+
const name = state?.pendingToolCalls?.get(toolUseId)?.toolName || state?.toolUseRegistry?.get(toolUseId)?.toolName;
|
|
121
|
+
return name ? `${name} image` : 'image';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convenience: collect images from `sources` and render them in one call.
|
|
126
|
+
* @param {Array<*>} sources - array of candidate nodes/containers
|
|
127
|
+
* @param {string} [label]
|
|
128
|
+
* @returns {Promise<string>}
|
|
129
|
+
*/
|
|
130
|
+
const section = async (sources, label) => render(collectImagePayloads(...(Array.isArray(sources) ? sources : [sources])), label);
|
|
131
|
+
|
|
132
|
+
return { uploader, collect: collectImagePayloads, render, toolLabel, section };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export default {
|
|
136
|
+
collectImagePayloads,
|
|
137
|
+
createImageRenderer,
|
|
138
|
+
extractImagePayload,
|
|
139
|
+
isImageNode,
|
|
140
|
+
};
|