@link-assistant/hive-mind 1.73.9 → 1.74.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 +51 -0
- package/README.hi.md +36 -0
- package/README.md +37 -0
- package/README.ru.md +37 -0
- package/README.zh.md +35 -0
- package/package.json +2 -1
- package/src/claude.lib.mjs +2 -0
- package/src/cleanup.lib.mjs +359 -0
- package/src/cleanup.mjs +288 -0
- package/src/cleanup.os.lib.mjs +404 -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/solve.config.lib.mjs +9 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive Mode Image Upload (Issue #1843)
|
|
4
|
+
*
|
|
5
|
+
* When Claude/Codex read or write images during an --interactive-mode session,
|
|
6
|
+
* interactive mode should show those images inline in the PR comments it posts.
|
|
7
|
+
*
|
|
8
|
+
* GitHub strips `data:` URIs from comment Markdown (its sanitizer only allows
|
|
9
|
+
* http/https/mailto/relative schemes), so embedding base64 inline does NOT work.
|
|
10
|
+
* The web "user-attachments" uploader is cookie-gated and rejects Personal Access
|
|
11
|
+
* Tokens (HTTP 422), so it cannot be driven headlessly either.
|
|
12
|
+
*
|
|
13
|
+
* The token-viable approach used here: store image blobs in a hidden custom Git
|
|
14
|
+
* ref (`refs/hive-mind-media/...`) via the Git Data API and embed a commit-SHA
|
|
15
|
+
* `?raw=true` blob URL. The custom ref keeps commits reachable without creating
|
|
16
|
+
* a branch or tag, while GitHub's Camo proxy renders the URL inline for authorized
|
|
17
|
+
* viewers in public and private repos.
|
|
18
|
+
*
|
|
19
|
+
* See docs/case-studies/issue-1843/ for the full analysis and sources.
|
|
20
|
+
*
|
|
21
|
+
* @module interactive-image-upload.lib.mjs
|
|
22
|
+
* @experimental
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { execFileAsync } from './interactive-mode.shared.lib.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hidden custom-ref namespace used to keep interactive-mode image commits alive
|
|
30
|
+
* without adding a branch or tag to the repository UI. Each PR gets its own ref:
|
|
31
|
+
* `refs/hive-mind-media/pr-<number>`.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MEDIA_REF_NAMESPACE = 'refs/hive-mind-media';
|
|
34
|
+
|
|
35
|
+
const README_CONTENT = `# hive-mind interactive media
|
|
36
|
+
|
|
37
|
+
This custom Git ref stores images that hive-mind's \`--interactive-mode\` read or
|
|
38
|
+
wrote during a session, so they can be embedded inline in PR comments.
|
|
39
|
+
|
|
40
|
+
It is created and updated automatically under \`refs/hive-mind-media/...\`. It is
|
|
41
|
+
not a branch or tag and is not meant to be checked out or merged. Files are
|
|
42
|
+
organized as \`media/pr-<number>/<sha256-prefix>.<ext>\` and de-duplicated by
|
|
43
|
+
content hash. See \`docs/case-studies/issue-1843/\` for details.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const EXT_BY_MEDIA_TYPE = {
|
|
47
|
+
'image/png': 'png',
|
|
48
|
+
'image/jpeg': 'jpg',
|
|
49
|
+
'image/jpg': 'jpg',
|
|
50
|
+
'image/gif': 'gif',
|
|
51
|
+
'image/webp': 'webp',
|
|
52
|
+
'image/svg+xml': 'svg',
|
|
53
|
+
'image/bmp': 'bmp',
|
|
54
|
+
'image/avif': 'avif',
|
|
55
|
+
'image/tiff': 'tiff',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map an image MIME type to a file extension. Defaults to `png`.
|
|
60
|
+
* @param {string} mediaType
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export const extensionForMediaType = mediaType => {
|
|
64
|
+
if (typeof mediaType !== 'string') return 'png';
|
|
65
|
+
const key = mediaType.toLowerCase().split(';')[0].trim();
|
|
66
|
+
return EXT_BY_MEDIA_TYPE[key] || 'png';
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sanitizeRefSegment = value => {
|
|
70
|
+
const segment = String(value || 'misc')
|
|
71
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
72
|
+
.replace(/^-+|-+$/g, '');
|
|
73
|
+
return segment || 'misc';
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const normalizeRefNamespace = namespace => {
|
|
77
|
+
const raw = String(namespace || DEFAULT_MEDIA_REF_NAMESPACE)
|
|
78
|
+
.replace(/\/+$/g, '')
|
|
79
|
+
.replace(/^\/+/g, '');
|
|
80
|
+
return raw.startsWith('refs/') ? raw : `refs/${raw}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the hidden custom Git ref used for one PR's media.
|
|
85
|
+
* @param {Object} [options]
|
|
86
|
+
* @param {number|string} [options.prNumber]
|
|
87
|
+
* @param {string} [options.namespace]
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export const buildMediaRef = ({ prNumber, namespace = DEFAULT_MEDIA_REF_NAMESPACE } = {}) => {
|
|
91
|
+
const prSeg = prNumber ? `pr-${sanitizeRefSegment(prNumber)}` : 'misc';
|
|
92
|
+
return `${normalizeRefNamespace(namespace)}/${prSeg}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const refPathForApi = fullRef =>
|
|
96
|
+
String(fullRef)
|
|
97
|
+
.replace(/^refs\//, '')
|
|
98
|
+
.split('/')
|
|
99
|
+
.map(seg => encodeURIComponent(seg))
|
|
100
|
+
.join('/');
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalize an image payload from any of the shapes Claude/Codex emit into
|
|
104
|
+
* `{ base64, mediaType }`, or return null if the node is not an image.
|
|
105
|
+
*
|
|
106
|
+
* Verified shapes (see docs/case-studies/issue-1843/external/research-notes.md):
|
|
107
|
+
* - Claude tool_result content: { type:'image', source:{ type:'base64', data, media_type } }
|
|
108
|
+
* - MCP image content: { type:'image', data, mimeType }
|
|
109
|
+
* - Claude Read tool_use_result: { type:'image', file:{ base64, type, originalSize } }
|
|
110
|
+
*
|
|
111
|
+
* @param {*} node
|
|
112
|
+
* @returns {{ base64: string, mediaType: string, originalSize?: number } | null}
|
|
113
|
+
*/
|
|
114
|
+
export const extractImagePayload = node => {
|
|
115
|
+
if (!node || typeof node !== 'object') return null;
|
|
116
|
+
|
|
117
|
+
// Claude tool_result image block: { type:'image', source:{ data, media_type } }
|
|
118
|
+
if (node.type === 'image' && node.source && typeof node.source === 'object' && typeof node.source.data === 'string' && node.source.data.length > 0) {
|
|
119
|
+
return { base64: node.source.data, mediaType: node.source.media_type || node.source.mimeType || 'image/png' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MCP image content: { type:'image', data, mimeType }
|
|
123
|
+
if (node.type === 'image' && typeof node.data === 'string' && node.data.length > 0) {
|
|
124
|
+
return { base64: node.data, mediaType: node.mimeType || node.media_type || 'image/png' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Claude Read tool_use_result: { type:'image', file:{ base64, type, originalSize } }
|
|
128
|
+
if (node.file && typeof node.file === 'object' && typeof node.file.base64 === 'string' && node.file.base64.length > 0) {
|
|
129
|
+
return { base64: node.file.base64, mediaType: node.file.type || node.file.media_type || 'image/png', originalSize: node.file.originalSize };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True when `extractImagePayload` would return a payload for this node.
|
|
137
|
+
* @param {*} node
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export const isImageNode = node => extractImagePayload(node) !== null;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build a Markdown-embeddable raw blob URL for a file committed at a reachable
|
|
144
|
+
* commit SHA. The custom media ref keeps that commit reachable without exposing a
|
|
145
|
+
* branch/tag, and the `?raw=true` URL renders inline in GitHub comments.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} owner
|
|
148
|
+
* @param {string} repo
|
|
149
|
+
* @param {string} commitSha
|
|
150
|
+
* @param {string} path
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export const buildRawBlobUrl = (owner, repo, commitSha, path) => {
|
|
154
|
+
const safePath = String(path)
|
|
155
|
+
.split('/')
|
|
156
|
+
.map(seg => encodeURIComponent(seg))
|
|
157
|
+
.join('/');
|
|
158
|
+
return `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(commitSha)}/${safePath}?raw=true`;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create an image uploader bound to a repository/PR.
|
|
163
|
+
*
|
|
164
|
+
* @param {Object} options
|
|
165
|
+
* @param {string} options.owner - Repository owner
|
|
166
|
+
* @param {string} options.repo - Repository name
|
|
167
|
+
* @param {number|string} [options.prNumber] - PR number (used to namespace paths/ref)
|
|
168
|
+
* @param {string} [options.mediaRef] - Full custom media ref (defaults to refs/hive-mind-media/pr-<n>)
|
|
169
|
+
* @param {string} [options.refNamespace] - Custom media ref namespace
|
|
170
|
+
* @param {Function} [options.log] - async logging function
|
|
171
|
+
* @param {boolean} [options.verbose=false]
|
|
172
|
+
* @param {Function} [options.execFile] - injected gh runner (for testing)
|
|
173
|
+
* @param {boolean} [options.enabled=true] - master switch
|
|
174
|
+
* @returns {{ uploadImage: Function, ensureMediaRef: Function, enabled: boolean, mediaRef: string, _state: Object }}
|
|
175
|
+
*/
|
|
176
|
+
export const createImageUploader = (options = {}) => {
|
|
177
|
+
const { owner, repo, prNumber, mediaRef = buildMediaRef({ prNumber, namespace: options.refNamespace }), log = async () => {}, verbose = false, execFile: execFileFn, enabled = true } = options;
|
|
178
|
+
|
|
179
|
+
const runGhApi = execFileFn || execFileAsync;
|
|
180
|
+
const mediaRefPath = refPathForApi(mediaRef);
|
|
181
|
+
|
|
182
|
+
// Memoized ref-readiness promise and a per-session content-hash -> URL cache.
|
|
183
|
+
let mediaRefReady = null;
|
|
184
|
+
let currentCommitSha = null;
|
|
185
|
+
let currentTreeSha = null;
|
|
186
|
+
let currentTreePaths = new Set();
|
|
187
|
+
const uploadedByHash = new Map();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Invoke `gh api` and parse the JSON response. For non-GET methods a JSON body
|
|
191
|
+
* is supplied on stdin via `--input -` (matches interactive-mode's postComment).
|
|
192
|
+
* @private
|
|
193
|
+
*/
|
|
194
|
+
const ghJson = async (apiPath, { method = 'GET', body } = {}) => {
|
|
195
|
+
const args = ['api', apiPath];
|
|
196
|
+
if (method && method !== 'GET') args.push('-X', method);
|
|
197
|
+
const execOpts = { maxBuffer: 16 * 1024 * 1024 };
|
|
198
|
+
if (body !== undefined) {
|
|
199
|
+
args.push('--input', '-');
|
|
200
|
+
execOpts.input = JSON.stringify(body);
|
|
201
|
+
}
|
|
202
|
+
const { stdout } = await runGhApi('gh', args, execOpts);
|
|
203
|
+
if (!stdout) return null;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(stdout);
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const rememberTreePaths = async treeSha => {
|
|
212
|
+
try {
|
|
213
|
+
const tree = await ghJson(`repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
|
|
214
|
+
currentTreePaths = new Set((Array.isArray(tree?.tree) ? tree.tree : []).filter(entry => entry?.type === 'blob' && typeof entry.path === 'string').map(entry => entry.path));
|
|
215
|
+
} catch {
|
|
216
|
+
currentTreePaths = new Set();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const rememberCommit = async sha => {
|
|
221
|
+
if (!sha) return false;
|
|
222
|
+
const commit = await ghJson(`repos/${owner}/${repo}/git/commits/${sha}`);
|
|
223
|
+
if (!commit?.sha || !commit?.tree?.sha) return false;
|
|
224
|
+
currentCommitSha = commit.sha;
|
|
225
|
+
currentTreeSha = commit.tree.sha;
|
|
226
|
+
await rememberTreePaths(currentTreeSha);
|
|
227
|
+
return true;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const fetchMediaRef = async () => {
|
|
231
|
+
try {
|
|
232
|
+
return await ghJson(`repos/${owner}/${repo}/git/ref/${mediaRefPath}`);
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const refreshMediaRef = async () => {
|
|
239
|
+
const ref = await fetchMediaRef();
|
|
240
|
+
return !!(ref?.object?.sha && (await rememberCommit(ref.object.sha)));
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ensure the custom media ref exists. Creates it once via the Git Data API
|
|
245
|
+
* (blob -> tree -> parentless commit -> ref). Memoized; resolves to a boolean.
|
|
246
|
+
* @returns {Promise<boolean>}
|
|
247
|
+
*/
|
|
248
|
+
const ensureMediaRef = async () => {
|
|
249
|
+
if (mediaRefReady) return mediaRefReady;
|
|
250
|
+
mediaRefReady = (async () => {
|
|
251
|
+
if (await refreshMediaRef()) return true;
|
|
252
|
+
try {
|
|
253
|
+
const readmeB64 = Buffer.from(README_CONTENT, 'utf8').toString('base64');
|
|
254
|
+
const blob = await ghJson(`repos/${owner}/${repo}/git/blobs`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
body: { content: readmeB64, encoding: 'base64' },
|
|
257
|
+
});
|
|
258
|
+
const tree = await ghJson(`repos/${owner}/${repo}/git/trees`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
body: { tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: blob.sha }] },
|
|
261
|
+
});
|
|
262
|
+
const commit = await ghJson(`repos/${owner}/${repo}/git/commits`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: { message: 'chore: initialize hive-mind interactive media ref', tree: tree.sha, parents: [] },
|
|
265
|
+
});
|
|
266
|
+
await ghJson(`repos/${owner}/${repo}/git/refs`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
body: { ref: mediaRef, sha: commit.sha },
|
|
269
|
+
});
|
|
270
|
+
currentCommitSha = commit.sha;
|
|
271
|
+
currentTreeSha = tree.sha;
|
|
272
|
+
currentTreePaths = new Set(['README.md']);
|
|
273
|
+
if (verbose) await log(`🖼️ Interactive mode: created media ref '${mediaRef}'`, { verbose: true });
|
|
274
|
+
return true;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
// A concurrent run may have created it between our check and create.
|
|
277
|
+
if (await refreshMediaRef()) return true;
|
|
278
|
+
await log(`⚠️ Interactive mode: could not prepare media ref '${mediaRef}': ${err.message}`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
return mediaRefReady;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const pathExistsAtCurrentCommit = async path => {
|
|
286
|
+
return !!(currentCommitSha && currentTreePaths.has(path));
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const existingUrl = async path => {
|
|
290
|
+
if (await pathExistsAtCurrentCommit(path)) {
|
|
291
|
+
return buildRawBlobUrl(owner, repo, currentCommitSha, path);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Upload one image and return a Markdown-embeddable URL, or null on failure /
|
|
298
|
+
* when disabled. De-duplicates by SHA-256 of the base64 content.
|
|
299
|
+
*
|
|
300
|
+
* @param {Object} image
|
|
301
|
+
* @param {string} image.base64 - base64-encoded image bytes
|
|
302
|
+
* @param {string} [image.mediaType] - MIME type (e.g. image/png)
|
|
303
|
+
* @param {string} [image.name] - human label for the commit message
|
|
304
|
+
* @returns {Promise<string|null>}
|
|
305
|
+
*/
|
|
306
|
+
const uploadImage = async ({ base64, mediaType, name } = {}) => {
|
|
307
|
+
if (!enabled) return null;
|
|
308
|
+
if (!owner || !repo) return null;
|
|
309
|
+
if (typeof base64 !== 'string' || base64.length === 0) return null;
|
|
310
|
+
|
|
311
|
+
const hash = createHash('sha256').update(base64).digest('hex');
|
|
312
|
+
if (uploadedByHash.has(hash)) return uploadedByHash.get(hash);
|
|
313
|
+
|
|
314
|
+
const ready = await ensureMediaRef();
|
|
315
|
+
if (!ready || !currentCommitSha || !currentTreeSha) return null;
|
|
316
|
+
|
|
317
|
+
const ext = extensionForMediaType(mediaType);
|
|
318
|
+
const short = hash.slice(0, 16);
|
|
319
|
+
const prSeg = prNumber ? `pr-${sanitizeRefSegment(prNumber)}` : 'misc';
|
|
320
|
+
const path = `media/${prSeg}/${short}.${ext}`;
|
|
321
|
+
|
|
322
|
+
const alreadyUploaded = await existingUrl(path);
|
|
323
|
+
if (alreadyUploaded) {
|
|
324
|
+
uploadedByHash.set(hash, alreadyUploaded);
|
|
325
|
+
return alreadyUploaded;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let lastError = null;
|
|
329
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
330
|
+
try {
|
|
331
|
+
const parentSha = currentCommitSha;
|
|
332
|
+
const blob = await ghJson(`repos/${owner}/${repo}/git/blobs`, {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
body: { content: base64, encoding: 'base64' },
|
|
335
|
+
});
|
|
336
|
+
const tree = await ghJson(`repos/${owner}/${repo}/git/trees`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
body: {
|
|
339
|
+
base_tree: currentTreeSha,
|
|
340
|
+
tree: [{ path, mode: '100644', type: 'blob', sha: blob.sha }],
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const commit = await ghJson(`repos/${owner}/${repo}/git/commits`, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
body: {
|
|
346
|
+
message: `chore: add interactive media ${name || short}`,
|
|
347
|
+
tree: tree.sha,
|
|
348
|
+
parents: [parentSha],
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
await ghJson(`repos/${owner}/${repo}/git/refs/${mediaRefPath}`, {
|
|
352
|
+
method: 'PATCH',
|
|
353
|
+
body: { sha: commit.sha, force: false },
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
currentCommitSha = commit.sha;
|
|
357
|
+
currentTreeSha = tree.sha;
|
|
358
|
+
currentTreePaths.add(path);
|
|
359
|
+
const url = buildRawBlobUrl(owner, repo, commit.sha, path);
|
|
360
|
+
uploadedByHash.set(hash, url);
|
|
361
|
+
if (verbose) await log(`🖼️ Interactive mode: uploaded image -> ${url}`, { verbose: true });
|
|
362
|
+
return url;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
lastError = err;
|
|
365
|
+
// Most likely a concurrent non-fast-forward update. Refresh once and retry.
|
|
366
|
+
if (attempt === 0 && (await refreshMediaRef())) {
|
|
367
|
+
const existingAfterRefresh = await existingUrl(path);
|
|
368
|
+
if (existingAfterRefresh) {
|
|
369
|
+
uploadedByHash.set(hash, existingAfterRefresh);
|
|
370
|
+
return existingAfterRefresh;
|
|
371
|
+
}
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const existingAfterFailure = await existingUrl(path);
|
|
378
|
+
if (existingAfterFailure) {
|
|
379
|
+
uploadedByHash.set(hash, existingAfterFailure);
|
|
380
|
+
return existingAfterFailure;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await log(`⚠️ Interactive mode: image upload failed (${path}): ${lastError?.message || 'unknown error'}`);
|
|
384
|
+
return null;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
uploadImage,
|
|
389
|
+
ensureMediaRef,
|
|
390
|
+
enabled,
|
|
391
|
+
mediaRef,
|
|
392
|
+
_state: {
|
|
393
|
+
uploadedByHash,
|
|
394
|
+
get currentCommitSha() {
|
|
395
|
+
return currentCommitSha;
|
|
396
|
+
},
|
|
397
|
+
get currentTreeSha() {
|
|
398
|
+
return currentTreeSha;
|
|
399
|
+
},
|
|
400
|
+
get currentTreePaths() {
|
|
401
|
+
return currentTreePaths;
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export default {
|
|
408
|
+
DEFAULT_MEDIA_REF_NAMESPACE,
|
|
409
|
+
buildMediaRef,
|
|
410
|
+
extensionForMediaType,
|
|
411
|
+
extractImagePayload,
|
|
412
|
+
isImageNode,
|
|
413
|
+
buildRawBlobUrl,
|
|
414
|
+
createImageUploader,
|
|
415
|
+
};
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
* @experimental
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { CONFIG, createCollapsible, createRawJsonSection, escapeMarkdown, execFileAsync, formatCost, formatDuration, getToolIcon, safeJsonStringify, sanitizeUnicode, truncateMiddle } from './interactive-mode.shared.lib.mjs';
|
|
35
|
+
import { CONFIG, createCollapsible, createRawJsonSection, createRedactedRawJsonSection, escapeMarkdown, execFileAsync, formatCost, formatDuration, getToolIcon, redactImageData, safeJsonStringify, sanitizeUnicode, truncateMiddle } from './interactive-mode.shared.lib.mjs';
|
|
36
|
+
// Issue #1843: turn base64 image tool-results into inline PR-comment images.
|
|
37
|
+
import { createImageRenderer, extractImagePayload, isImageNode } from './interactive-image-render.lib.mjs';
|
|
36
38
|
// Issue #1625: track interactive-mode comment IDs so they're excluded from
|
|
37
39
|
// the "did the AI post anything?" check in checkForAiCreatedComments().
|
|
38
40
|
// Use the session-started marker as the single source of truth for the
|
|
@@ -75,6 +77,11 @@ export const createInteractiveHandler = options => {
|
|
|
75
77
|
// Pre-existing user content carve-out (issue body / non-bot comments /
|
|
76
78
|
// pre-existing code). When provided, sanitizer leaves these tokens untouched.
|
|
77
79
|
excludeTokens = [],
|
|
80
|
+
// Issue #1843: when true (default), base64 tool-result images are embedded
|
|
81
|
+
// inline; when false they degrade to a metadata note. See createImageRenderer.
|
|
82
|
+
imageUploadEnabled = true,
|
|
83
|
+
mediaRef,
|
|
84
|
+
imageUploader: injectedImageUploader,
|
|
78
85
|
} = options;
|
|
79
86
|
// Use injected execFile for testability, or the real one by default
|
|
80
87
|
const runGhApi = execFileFn || execFileAsync;
|
|
@@ -111,6 +118,8 @@ export const createInteractiveHandler = options => {
|
|
|
111
118
|
editsFailed: 0,
|
|
112
119
|
};
|
|
113
120
|
|
|
121
|
+
const imageRenderer = createImageRenderer({ owner, repo, prNumber, mediaRef, log, verbose, execFile: execFileFn, enabled: imageUploadEnabled, uploader: injectedImageUploader, state }); // Issue #1843
|
|
122
|
+
|
|
114
123
|
/**
|
|
115
124
|
* Sanitize a comment body and warn the chat owner when a known-local token
|
|
116
125
|
* was about to be published. Issue #1745. The returned string is what we
|
|
@@ -632,6 +641,8 @@ ${createRawJsonSection(data)}`;
|
|
|
632
641
|
.map(c => {
|
|
633
642
|
if (typeof c === 'string') return c;
|
|
634
643
|
if (c.type === 'text') return c.text || '';
|
|
644
|
+
// Issue #1843: image bytes render in the section below, not the fence.
|
|
645
|
+
if (isImageNode(c)) return `_[image: ${extractImagePayload(c).mediaType}]_`;
|
|
635
646
|
return safeJsonStringify(c);
|
|
636
647
|
})
|
|
637
648
|
.join('\n');
|
|
@@ -644,6 +655,10 @@ ${createRawJsonSection(data)}`;
|
|
|
644
655
|
keepEnd: 25,
|
|
645
656
|
});
|
|
646
657
|
|
|
658
|
+
// Issue #1843: render images this result read/produced (used by both paths).
|
|
659
|
+
const imagesSection = await imageRenderer.section([toolResult.content, data.tool_use_result], imageRenderer.toolLabel(toolUseId));
|
|
660
|
+
const imagesBlock = imagesSection ? `${imagesSection}\n\n` : '';
|
|
661
|
+
|
|
647
662
|
// Check if we have a pending tool call to merge with
|
|
648
663
|
const pendingCall = state.pendingToolCalls.get(toolUseId);
|
|
649
664
|
|
|
@@ -703,9 +718,9 @@ ${inputDisplay}
|
|
|
703
718
|
|
|
704
719
|
${createCollapsible(`📤 Output (${statusIcon} ${statusText.toLowerCase()})`, '```\n' + escapeMarkdown(truncatedContent) + '\n```', true)}
|
|
705
720
|
|
|
706
|
-
---
|
|
721
|
+
${imagesBlock}---
|
|
707
722
|
|
|
708
|
-
${
|
|
723
|
+
${createRedactedRawJsonSection([toolData, data])}`;
|
|
709
724
|
|
|
710
725
|
// Edit the existing comment
|
|
711
726
|
const editSuccess = await editComment(commentId, mergedComment);
|
|
@@ -742,9 +757,9 @@ ${createRawJsonSection([toolData, data])}`;
|
|
|
742
757
|
|
|
743
758
|
${createCollapsible(`📤 Output (${statusIcon} ${statusText.toLowerCase()})`, '```\n' + escapeMarkdown(truncatedContent) + '\n```', true)}
|
|
744
759
|
|
|
745
|
-
---
|
|
760
|
+
${imagesBlock}---
|
|
746
761
|
|
|
747
|
-
${
|
|
762
|
+
${createRedactedRawJsonSection(data)}`;
|
|
748
763
|
|
|
749
764
|
await postComment(comment);
|
|
750
765
|
|
|
@@ -1153,17 +1168,20 @@ ${createRawJsonSection(data)}`;
|
|
|
1153
1168
|
const item = data.item || {};
|
|
1154
1169
|
const summary = [`**Server:** \`${item.server || 'unknown'}\``, `**Tool:** \`${item.tool || 'unknown'}\``, `**Status:** \`${item.status || 'unknown'}\``].join('\n');
|
|
1155
1170
|
const details = item.arguments != null ? createCollapsible('📥 Arguments', '```json\n' + safeJsonStringify(item.arguments, 2) + '\n```', true) : '';
|
|
1156
|
-
|
|
1171
|
+
// Issue #1843: render MCP-result images inline; base64 is redacted from JSON below.
|
|
1172
|
+
const imagesSection = await imageRenderer.section([item.result], `${item.tool || 'mcp'} image`);
|
|
1173
|
+
const imagesBlock = imagesSection ? '\n\n' + imagesSection : '';
|
|
1174
|
+
const resultSection = item.result != null ? '\n\n' + createCollapsible('📤 Result', '```json\n' + safeJsonStringify(redactImageData(item.result), 2) + '\n```', false) : '';
|
|
1157
1175
|
const errorSection = item.error != null ? '\n\n' + createCollapsible('❌ Error', '```json\n' + safeJsonStringify(item.error, 2) + '\n```', true) : '';
|
|
1158
1176
|
|
|
1159
1177
|
await postComment(`## 🔌 Codex MCP tool call
|
|
1160
1178
|
|
|
1161
1179
|
${summary}
|
|
1162
|
-
${details}${resultSection}${errorSection}
|
|
1180
|
+
${details}${resultSection}${imagesBlock}${errorSection}
|
|
1163
1181
|
|
|
1164
1182
|
---
|
|
1165
1183
|
|
|
1166
|
-
${
|
|
1184
|
+
${createRedactedRawJsonSection(data)}`);
|
|
1167
1185
|
};
|
|
1168
1186
|
|
|
1169
1187
|
const handleCodexWebSearch = async data => {
|
|
@@ -1388,6 +1406,7 @@ ${createRawJsonSection(data)}`;
|
|
|
1388
1406
|
processEvent,
|
|
1389
1407
|
flush,
|
|
1390
1408
|
getState,
|
|
1409
|
+
imageUploader: imageRenderer.uploader, // Issue #1843: exposed for callers/tests.
|
|
1391
1410
|
// Expose individual handlers for testing
|
|
1392
1411
|
_handlers: {
|
|
1393
1412
|
handleSystemInit,
|