@yeaft/webchat-agent 1.0.177 → 1.0.180

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.
@@ -113,6 +113,7 @@ export async function handleMessage(msg) {
113
113
 
114
114
  // ★ Flush 断连期间缓冲的消息
115
115
  await flushMessageBuffer();
116
+ await ctx.assetOutbox?.drain();
116
117
 
117
118
  // ★ Phase 1: 通知 server 同步完成
118
119
  sendToServer({ type: 'agent_sync_complete' });
@@ -130,6 +131,12 @@ export async function handleMessage(msg) {
130
131
  .finally(() => { preloadYeaftSkillSlashCommands(); });
131
132
  break;
132
133
 
134
+ case 'yeaft_asset_ack':
135
+ if ((msg.ok === true || msg.permanent === true) && ctx.assetOutbox?.acknowledge(msg.deliveryId)) {
136
+ ctx.assetOutbox.drain().catch(err => console.warn('[AssetOutbox] drain failed:', err?.message || err));
137
+ }
138
+ break;
139
+
133
140
  case 'create_conversation':
134
141
  await createConversation(msg);
135
142
  break;
package/context.js CHANGED
@@ -47,6 +47,7 @@ export default {
47
47
  lastHeartbeatStallMs: 0,
48
48
  outboundSendQueue: [],
49
49
  outboundSendQueueActive: false,
50
+ assetOutbox: null,
50
51
  // 断连期间的消息缓冲队列(重连后 flush)
51
52
  messageBuffer: [],
52
53
  messageBufferMaxSize: 5000, // 防止内存无限增长
package/index.js CHANGED
@@ -3,6 +3,7 @@ assertNodeVersion({ component: '@yeaft/webchat-agent' });
3
3
 
4
4
  import 'dotenv/config';
5
5
  import { platform, homedir } from 'os';
6
+ import { createAssetOutbox } from './yeaft/asset-outbox.js';
6
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, chmodSync, readdirSync } from 'fs';
7
8
  import { join, dirname } from 'path';
8
9
  import { exec } from 'child_process';
@@ -107,6 +108,10 @@ const CONFIG = {
107
108
  // 初始化共享上下文
108
109
  ctx.CONFIG = CONFIG;
109
110
  ctx.saveConfig = saveConfig;
111
+ ctx.assetOutbox = createAssetOutbox({
112
+ root: join(YEAFT_DIR, 'asset-outbox'),
113
+ send: message => ctx.sendToServer?.(message) || 'dropped',
114
+ });
110
115
 
111
116
  // 初始加载 MCP servers(必须在 ctx.CONFIG 赋值之后)
112
117
  loadMcpServers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.177",
3
+ "version": "1.0.180",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,124 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { writeAtomic } from './storage/atomic.js';
5
+
6
+ const DEFAULT_MAX_ITEMS = 128;
7
+ const DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
8
+
9
+ function safeDeliveryId(value) {
10
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{16,128}$/.test(value) ? value : null;
11
+ }
12
+
13
+ export function createAssetOutbox({
14
+ root,
15
+ send,
16
+ maxItems = DEFAULT_MAX_ITEMS,
17
+ maxBytes = DEFAULT_MAX_BYTES,
18
+ retryDelayMs = 30_000,
19
+ } = {}) {
20
+ if (!root) throw new Error('Asset outbox root is required');
21
+ if (typeof send !== 'function') throw new Error('Asset outbox send function is required');
22
+ mkdirSync(root, { recursive: true });
23
+ let draining = null;
24
+ let retryTimer = null;
25
+
26
+ const itemPath = deliveryId => join(root, `${deliveryId}.json`);
27
+ const list = () => readdirSync(root)
28
+ .filter(name => /^[A-Za-z0-9_-]{16,128}\.json$/.test(name))
29
+ .map(name => {
30
+ const path = join(root, name);
31
+ try {
32
+ const stat = statSync(path);
33
+ return { path, size: stat.size, createdAt: stat.birthtimeMs || stat.mtimeMs };
34
+ } catch { return null; }
35
+ })
36
+ .filter(Boolean)
37
+ .sort((a, b) => a.createdAt - b.createdAt || a.path.localeCompare(b.path));
38
+
39
+ const read = path => {
40
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
41
+ };
42
+
43
+ function enforceCapacity(extraBytes) {
44
+ const items = list();
45
+ const bytes = items.reduce((sum, item) => sum + item.size, 0);
46
+ if (items.length >= maxItems || bytes + extraBytes > maxBytes) {
47
+ throw new Error('Asset outbox is full; generated image was not queued');
48
+ }
49
+ }
50
+
51
+ function enqueue(message) {
52
+ const deliveryId = safeDeliveryId(message?.deliveryId) || randomUUID();
53
+ const payload = { ...message, type: 'yeaft_asset_put', deliveryId, queuedAt: Date.now() };
54
+ const data = JSON.stringify(payload);
55
+ enforceCapacity(Buffer.byteLength(data));
56
+ writeAtomic(itemPath(deliveryId), data);
57
+ return deliveryId;
58
+ }
59
+
60
+ function acknowledge(deliveryId) {
61
+ const id = safeDeliveryId(deliveryId);
62
+ if (!id) return false;
63
+ const path = itemPath(id);
64
+ if (!existsSync(path)) return false;
65
+ rmSync(path, { force: true });
66
+ return true;
67
+ }
68
+
69
+ const scheduleRetry = () => {
70
+ if (retryTimer || list().length === 0) return;
71
+ retryTimer = setTimeout(() => {
72
+ retryTimer = null;
73
+ drain().catch(err => console.warn('[AssetOutbox] retry failed:', err?.message || err));
74
+ }, retryDelayMs);
75
+ retryTimer.unref?.();
76
+ };
77
+
78
+ async function drain() {
79
+ if (draining) return draining;
80
+ if (retryTimer) {
81
+ clearTimeout(retryTimer);
82
+ retryTimer = null;
83
+ }
84
+ draining = (async () => {
85
+ for (const item of list()) {
86
+ const payload = read(item.path);
87
+ if (!payload || !safeDeliveryId(payload.deliveryId)) {
88
+ rmSync(item.path, { force: true });
89
+ continue;
90
+ }
91
+ let outcome;
92
+ try { outcome = await send(payload); } catch { outcome = 'dropped'; }
93
+ if (outcome !== 'sent') break;
94
+ }
95
+ })().finally(() => {
96
+ draining = null;
97
+ scheduleRetry();
98
+ });
99
+ return draining;
100
+ }
101
+
102
+ function removeSession(sessionId) {
103
+ let removed = 0;
104
+ for (const item of list()) {
105
+ const payload = read(item.path);
106
+ if (payload?.sessionId !== sessionId) continue;
107
+ rmSync(item.path, { force: true });
108
+ removed++;
109
+ }
110
+ return removed;
111
+ }
112
+
113
+ return {
114
+ enqueue,
115
+ acknowledge,
116
+ drain,
117
+ removeSession,
118
+ list,
119
+ close() {
120
+ if (retryTimer) clearTimeout(retryTimer);
121
+ retryTimer = null;
122
+ },
123
+ };
124
+ }
@@ -263,6 +263,7 @@ function serializeMessage(msg) {
263
263
  // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
264
264
  fm.push(`threadId: ${msg.threadId || 'main'}`);
265
265
  if (msg.turnId) fm.push(`turnId: ${msg.turnId}`);
266
+ if (msg.imageAssetAnchor) fm.push('imageAssetAnchor: true');
266
267
  // task-313: when a thread is merged into another, the messages keep
267
268
  // their original thread id in `sourceThreadId` so the UI can still
268
269
  // render a small "#source" pill next to each bubble.
@@ -285,6 +286,12 @@ function serializeMessage(msg) {
285
286
  fm.push(`attachmentsB64: ${b64}`);
286
287
  } catch { /* best-effort: attachments are UI metadata, not engine-critical */ }
287
288
  }
289
+ if (Array.isArray(msg.images) && msg.images.length > 0) {
290
+ try {
291
+ const b64 = Buffer.from(JSON.stringify(msg.images)).toString('base64');
292
+ fm.push(`imagesB64: ${b64}`);
293
+ } catch { /* best-effort: image display metadata is not engine-critical */ }
294
+ }
288
295
  // Internal/synthetic rows must round-trip so refresh/history replay can
289
296
  // keep them out of the user-visible conversation. Reflection folding uses
290
297
  // `_reflection`; other engine-only rows may use one of the explicit flags.
@@ -392,6 +399,7 @@ export function parseMessage(raw) {
392
399
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
393
400
  case 'threadId': msg.threadId = value; break;
394
401
  case 'turnId': msg.turnId = value; break;
402
+ case 'imageAssetAnchor': msg.imageAssetAnchor = value === 'true'; break;
395
403
  case 'sourceThreadId': msg.sourceThreadId = value; break;
396
404
  case 'sessionId': msg.sessionId = value; break;
397
405
  case 'chatId': msg.chatId = value; break;
@@ -403,6 +411,12 @@ export function parseMessage(raw) {
403
411
  if (Array.isArray(parsed)) msg.attachments = parsed;
404
412
  } catch { /* best-effort: ignore malformed attachment metadata */ }
405
413
  break;
414
+ case 'imagesB64':
415
+ try {
416
+ const parsed = JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
417
+ if (Array.isArray(parsed)) msg.images = parsed;
418
+ } catch { /* best-effort: ignore malformed image metadata */ }
419
+ break;
406
420
  case '_reflection': msg._reflection = value === 'true'; break;
407
421
  case 'internal': msg.internal = value === 'true'; break;
408
422
  case 'systemOnly': msg.systemOnly = value === 'true'; break;
package/yeaft/engine.js CHANGED
@@ -47,6 +47,7 @@ import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/c
47
47
  import { resolveThinking } from './router/thinking.js';
48
48
  import { approxTokens } from './memory/budget.js';
49
49
  import { COLLAB_TOOL_POLICY, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
50
+ import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
50
51
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
51
52
  import {
52
53
  TOOL_BATCH_SIZE,
@@ -2183,6 +2184,7 @@ export class Engine {
2183
2184
  let continueTurns = 0; // auto-continue counter
2184
2185
  let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
2185
2186
  let fullResponseText = '';
2187
+ let hasDisplayImageAnchor = false;
2186
2188
  let currentModel = this.#config.model;
2187
2189
  let cumulativeInputTokens = 0;
2188
2190
  let cumulativeOutputTokens = 0;
@@ -3068,6 +3070,7 @@ export class Engine {
3068
3070
  // for this turn. The hook still persists assistant + tool
3069
3071
  // rows for THIS VP's contribution.
3070
3072
  userAlreadyPersisted,
3073
+ hasDisplayImageAnchor,
3071
3074
  });
3072
3075
 
3073
3076
  if (hookResult.consolidated) {
@@ -3252,6 +3255,7 @@ export class Engine {
3252
3255
  }
3253
3256
 
3254
3257
  let output;
3258
+ let displayImages = [];
3255
3259
  let isError = false;
3256
3260
  currentToolCallForAsyncTask = {
3257
3261
  id: tc.id,
@@ -3285,7 +3289,12 @@ export class Engine {
3285
3289
  const rawOutput = await tool.execute(tc.input, toolCtx);
3286
3290
  output = normalizeToolOutput(rawOutput);
3287
3291
  }
3288
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
3292
+ displayImages = extractDisplayImages(tc.name, output);
3293
+ if (displayImages.length > 0) {
3294
+ output = stripDisplayImageData(output, displayImages);
3295
+ }
3296
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError: false, threadId: this.currentThreadId };
3297
+ if (displayImages.some(image => image.deliveryQueued === true)) hasDisplayImageAnchor = true;
3289
3298
  } catch (err) {
3290
3299
  output = `Error: ${err.message}`;
3291
3300
  isError = true;
@@ -3310,6 +3319,7 @@ export class Engine {
3310
3319
  durationMs: toolDurationMs,
3311
3320
  isError,
3312
3321
  toolOutput: output,
3322
+ ...(displayImages.length > 0 ? { displayImageCount: displayImages.length } : {}),
3313
3323
  };
3314
3324
 
3315
3325
  // 2026-05-13: feed the per-tool counters. Stays best-effort — a
@@ -3334,7 +3344,6 @@ export class Engine {
3334
3344
  toolOutput: output,
3335
3345
  durationMs: toolDurationMs,
3336
3346
  isError,
3337
- toolOutput: output,
3338
3347
  });
3339
3348
 
3340
3349
  // Append only the bounded copy to the model message history. Raw
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const SUPPORTED_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
4
+ const MAX_UI_IMAGE_BYTES = 20 * 1024 * 1024;
5
+
6
+ function parseJsonObject(value) {
7
+ if (value && typeof value === 'object') return value;
8
+ if (typeof value !== 'string') return null;
9
+ const trimmed = value.trim();
10
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
11
+ try { return JSON.parse(trimmed); } catch { return null; }
12
+ }
13
+
14
+ function decodeDataUri(value) {
15
+ const match = typeof value === 'string'
16
+ ? value.match(/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/)
17
+ : null;
18
+ if (!match) return null;
19
+ const mimeType = match[1].toLowerCase();
20
+ if (!SUPPORTED_IMAGE_MIME.has(mimeType)) return null;
21
+ const data = match[2].replace(/\s+/g, '');
22
+ let buffer;
23
+ try { buffer = Buffer.from(data, 'base64'); } catch { return null; }
24
+ if (!buffer.length || buffer.length > MAX_UI_IMAGE_BYTES) return null;
25
+ return { mimeType, data, buffer };
26
+ }
27
+
28
+ function imageCandidates(payload) {
29
+ if (!payload) return [];
30
+ if (Array.isArray(payload)) return payload;
31
+ const out = [];
32
+ if (payload.image) out.push(typeof payload.image === 'object' ? payload.image : { image: payload.image });
33
+ if (payload.dataUri) out.push({ dataUri: payload.dataUri });
34
+ if (Array.isArray(payload.images)) out.push(...payload.images);
35
+ return out;
36
+ }
37
+
38
+ export function extractDisplayImages(toolName, output) {
39
+ const payload = parseJsonObject(output);
40
+ const images = [];
41
+ const seen = new Set();
42
+ for (const item of imageCandidates(payload)) {
43
+ const dataUri = typeof item === 'string'
44
+ ? item
45
+ : (item?.dataUri || item?.image || (item?.data && item?.mimeType ? `data:${item.mimeType};base64,${item.data}` : null));
46
+ const decoded = decodeDataUri(dataUri);
47
+ if (!decoded) continue;
48
+ const assetId = createHash('sha256').update(decoded.buffer).digest('hex');
49
+ if (seen.has(assetId)) continue;
50
+ seen.add(assetId);
51
+ const filename = String(item?.filename || payload?.filename || payload?.path || `${toolName || 'image'}-${assetId.slice(0, 12)}`)
52
+ .split(/[/\\]/)
53
+ .pop();
54
+ images.push({
55
+ assetId,
56
+ mimeType: decoded.mimeType,
57
+ filename,
58
+ size: decoded.buffer.length,
59
+ width: Number.isFinite(item?.width ?? payload?.width) ? Number(item?.width ?? payload.width) : null,
60
+ height: Number.isFinite(item?.height ?? payload?.height) ? Number(item?.height ?? payload.height) : null,
61
+ previewData: { data: decoded.data, mimeType: decoded.mimeType, filename },
62
+ });
63
+ }
64
+ return images;
65
+ }
66
+
67
+ export function imageMetadataForPersistence(image) {
68
+ if (!image?.assetId || !image?.mimeType) return null;
69
+ return {
70
+ assetId: image.assetId,
71
+ mimeType: image.mimeType,
72
+ filename: image.filename || 'image',
73
+ size: Number(image.size) || null,
74
+ width: Number(image.width) || null,
75
+ height: Number(image.height) || null,
76
+ };
77
+ }
78
+
79
+ export function stripDisplayImageData(output, images) {
80
+ if (!Array.isArray(images) || images.length === 0) {
81
+ return typeof output === 'string' ? output : JSON.stringify(output ?? '');
82
+ }
83
+ const payload = parseJsonObject(output);
84
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '[image asset]';
85
+ const clean = { ...payload };
86
+ delete clean.image;
87
+ delete clean.images;
88
+ delete clean.dataUri;
89
+ if (typeof clean.data === 'string' && clean.data.length > 1024) delete clean.data;
90
+ return JSON.stringify({ ...clean, imageAssetIds: images.map(image => image.assetId) });
91
+ }
@@ -0,0 +1,199 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isIP } from 'node:net';
5
+
6
+ export const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
7
+ const MAX_REDIRECTS = 5;
8
+
9
+ function parseIpv4(address) {
10
+ const parts = address.split('.').map(Number);
11
+ return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255) ? parts : null;
12
+ }
13
+
14
+ function parseIpv6(address) {
15
+ const normalized = address.toLowerCase().split('%')[0];
16
+ if (!normalized || normalized.includes(':::')) return null;
17
+ const halves = normalized.split('::');
18
+ if (halves.length > 2) return null;
19
+ const parseHalf = (value) => {
20
+ if (!value) return [];
21
+ const groups = [];
22
+ for (const part of value.split(':')) {
23
+ if (part.includes('.')) {
24
+ const bytes = parseIpv4(part);
25
+ if (!bytes) return null;
26
+ groups.push((bytes[0] << 8) | bytes[1], (bytes[2] << 8) | bytes[3]);
27
+ } else if (!/^[0-9a-f]{1,4}$/.test(part)) {
28
+ return null;
29
+ } else {
30
+ groups.push(Number.parseInt(part, 16));
31
+ }
32
+ }
33
+ return groups;
34
+ };
35
+ const left = parseHalf(halves[0]);
36
+ const right = parseHalf(halves[1] || '');
37
+ if (!left || !right) return null;
38
+ if (halves.length === 1) return left.length === 8 ? left : null;
39
+ const zeros = 8 - left.length - right.length;
40
+ if (zeros < 1) return null;
41
+ return [...left, ...Array(zeros).fill(0), ...right];
42
+ }
43
+
44
+ function ipv4FromGroups(groups, index) {
45
+ const high = groups[index];
46
+ const low = groups[index + 1];
47
+ return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
48
+ }
49
+
50
+ export function isPublicNetworkAddress(address) {
51
+ const version = isIP(address);
52
+ if (version === 4) {
53
+ const bytes = parseIpv4(address);
54
+ if (!bytes) return false;
55
+ const [a, b, c] = bytes;
56
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
57
+ if (a === 100 && b >= 64 && b <= 127) return false;
58
+ if (a === 169 && b === 254) return false;
59
+ if (a === 172 && b >= 16 && b <= 31) return false;
60
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return false;
61
+ if (a === 192 && b === 88 && c === 99) return false;
62
+ if (a === 192 && b === 168) return false;
63
+ if (a === 198 && (b === 18 || b === 19)) return false;
64
+ if (a === 198 && b === 51 && c === 100) return false;
65
+ if (a === 203 && b === 0 && c === 113) return false;
66
+ return true;
67
+ }
68
+ if (version !== 6) return false;
69
+ const normalized = address.toLowerCase().split('%')[0];
70
+ if (normalized === '::' || normalized === '::1') return false;
71
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return false;
72
+ if (/^fe[89ab]/.test(normalized)) return false;
73
+ if (normalized.startsWith('ff')) return false;
74
+ const groups = parseIpv6(normalized);
75
+ if (!groups) return false;
76
+ const isMappedIpv4 = groups.slice(0, 5).every(group => group === 0) && groups[5] === 0xffff;
77
+ if (isMappedIpv4) return isPublicNetworkAddress(ipv4FromGroups(groups, 6));
78
+ const isCompatibleIpv4 = groups.slice(0, 6).every(group => group === 0);
79
+ const isNat64 = (groups[0] === 0x0064 && groups[1] === 0xff9b && groups.slice(2, 6).every(group => group === 0))
80
+ || (groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 0x0001);
81
+ const is6to4 = groups[0] === 0x2002;
82
+ const isTeredo = groups[0] === 0x2001 && groups[1] === 0;
83
+ const isIsatap = groups[5] === 0x5efe;
84
+ if (isCompatibleIpv4 || isNat64 || is6to4 || isTeredo || isIsatap) return false;
85
+ return !(groups[0] === 0x2001 && groups[1] === 0x0db8);
86
+ }
87
+
88
+ async function resolvePublicTarget(url, lookup = dnsLookup) {
89
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Generated image URL must use http or https');
90
+ if (url.username || url.password) throw new Error('Generated image URL must not contain credentials');
91
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
92
+ const literalVersion = isIP(hostname);
93
+ const records = literalVersion
94
+ ? [{ address: hostname, family: literalVersion }]
95
+ : await lookup(hostname, { all: true, verbatim: true });
96
+ if (!Array.isArray(records) || records.length === 0) throw new Error('Generated image host did not resolve');
97
+ if (records.some(record => !isPublicNetworkAddress(record.address))) {
98
+ throw new Error('Generated image URL resolves to a private or reserved network');
99
+ }
100
+ return records[0];
101
+ }
102
+
103
+ function detectMime(buffer) {
104
+ if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
105
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
106
+ const head6 = buffer.subarray(0, 6).toString('ascii');
107
+ if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif';
108
+ if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
109
+ return null;
110
+ }
111
+
112
+ function requestOnce(url, target, { signal, requestImpl } = {}) {
113
+ return new Promise((resolve, reject) => {
114
+ const request = requestImpl || (url.protocol === 'https:' ? httpsRequest : httpRequest);
115
+ let settled = false;
116
+ const finishReject = (err) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ reject(err);
120
+ };
121
+ const req = request(url, {
122
+ signal,
123
+ lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
124
+ headers: { accept: 'image/png,image/jpeg,image/gif,image/webp' },
125
+ }, response => {
126
+ if (settled) {
127
+ response.destroy();
128
+ return;
129
+ }
130
+ settled = true;
131
+ resolve(response);
132
+ });
133
+ req.on('error', finishReject);
134
+ req.end();
135
+ });
136
+ }
137
+
138
+ async function readBounded(response, maxBytes) {
139
+ const declared = Number(response.headers['content-length']);
140
+ if (Number.isFinite(declared) && declared > maxBytes) {
141
+ response.destroy();
142
+ throw new Error('Generated image exceeds 20 MiB');
143
+ }
144
+ const chunks = [];
145
+ let total = 0;
146
+ try {
147
+ for await (const chunk of response) {
148
+ total += chunk.length;
149
+ if (total > maxBytes) {
150
+ response.destroy();
151
+ throw new Error('Generated image exceeds 20 MiB');
152
+ }
153
+ chunks.push(chunk);
154
+ }
155
+ } catch (err) {
156
+ response.destroy();
157
+ throw err;
158
+ }
159
+ if (total === 0) throw new Error('Generated image is empty');
160
+ return Buffer.concat(chunks, total);
161
+ }
162
+
163
+ export async function downloadRemoteImage(value, {
164
+ signal,
165
+ lookup = dnsLookup,
166
+ requestImpl,
167
+ maxBytes = MAX_REMOTE_IMAGE_BYTES,
168
+ maxRedirects = MAX_REDIRECTS,
169
+ } = {}) {
170
+ let url;
171
+ try { url = new URL(value); } catch { throw new Error('Image API returned an invalid image URL'); }
172
+ for (let redirects = 0; ; redirects++) {
173
+ const target = await resolvePublicTarget(url, lookup);
174
+ const response = await requestOnce(url, target, { signal, requestImpl });
175
+ const status = Number(response.statusCode || 0);
176
+ if (status >= 300 && status < 400 && response.headers.location) {
177
+ response.destroy();
178
+ if (redirects >= maxRedirects) throw new Error('Generated image download exceeded redirect limit');
179
+ const nextUrl = new URL(response.headers.location, url);
180
+ if (url.protocol === 'https:' && nextUrl.protocol !== 'https:') {
181
+ throw new Error('Generated image redirect must not downgrade HTTPS');
182
+ }
183
+ url = nextUrl;
184
+ continue;
185
+ }
186
+ if (status < 200 || status >= 300) {
187
+ response.destroy();
188
+ throw new Error(`Image download returned ${status}`);
189
+ }
190
+ const mimeType = String(response.headers['content-type'] || '').split(';')[0].toLowerCase();
191
+ if (!['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(mimeType)) {
192
+ response.destroy();
193
+ throw new Error(`Unsupported generated image type: ${mimeType}`);
194
+ }
195
+ const buffer = await readBounded(response, maxBytes);
196
+ if (detectMime(buffer) !== mimeType) throw new Error('Generated image MIME does not match file bytes');
197
+ return { buffer, mimeType, url: url.href };
198
+ }
199
+ }
@@ -74,6 +74,7 @@ export async function runStopHooks(context) {
74
74
  // row exactly once before fan-out. Each VP's stop-hook then skips
75
75
  // the user record but still writes its own assistant + tool rows.
76
76
  userAlreadyPersisted = false,
77
+ hasDisplayImageAnchor = false,
77
78
  } = context;
78
79
 
79
80
  // Model name for persisted messages: use primaryModel if provided, else config.model
@@ -125,6 +126,15 @@ export async function runStopHooks(context) {
125
126
  }
126
127
  }
127
128
  const recentMessages = messages.slice(turnStart);
129
+ const isPersistable = (msg) => Boolean(msg?.role) && (
130
+ (typeof msg.content === 'string' && msg.content.length > 0) ||
131
+ (msg.content && typeof msg.content !== 'string') ||
132
+ (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) ||
133
+ msg.role === 'tool'
134
+ );
135
+ const imageAnchorMessage = hasDisplayImageAnchor
136
+ ? recentMessages.findLast(msg => msg?.role === 'assistant' && isPersistable(msg)) || null
137
+ : null;
128
138
  for (const msg of recentMessages) {
129
139
  if (!msg || !msg.role) continue;
130
140
  // Skip the user row if the orchestrator already wrote it once for
@@ -145,12 +155,7 @@ export async function runStopHooks(context) {
145
155
  if (userAlreadyPersisted && msg.role === 'user' && msg === recentMessages[0]) continue;
146
156
  // Allow empty assistant content when toolCalls are present;
147
157
  // tool messages have content by construction.
148
- const hasContent =
149
- (typeof msg.content === 'string' && msg.content.length > 0) ||
150
- (msg.content && typeof msg.content !== 'string') ||
151
- (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) ||
152
- msg.role === 'tool';
153
- if (!hasContent) continue;
158
+ if (!isPersistable(msg)) continue;
154
159
 
155
160
  const record = {
156
161
  role: msg.role,
@@ -164,6 +169,7 @@ export async function runStopHooks(context) {
164
169
  if (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
165
170
  record.toolCalls = msg.toolCalls;
166
171
  }
172
+ if (msg === imageAnchorMessage) record.imageAssetAnchor = true;
167
173
  if (msg.isError) record.isError = true;
168
174
  // Bug 6: stamp sessionId / threadId so replay can re-route by group.
169
175
  if (sessionId) record.sessionId = sessionId;