@yeaft/webchat-agent 1.0.178 → 1.0.181

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.178",
3
+ "version": "1.0.181",
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
+ }
@@ -6,7 +6,7 @@
6
6
  * 1. Chain depth — a message's `causedBy` chain (A → @B → @C → @A …) must
7
7
  * not exceed a max depth. Each route_forward call stamps the outbound
8
8
  * envelope's `meta.causedBy` with a chain of msgIds, and the guard
9
- * rejects when the chain length would exceed MAX_CHAIN_DEPTH (10).
9
+ * rejects when the chain length would exceed MAX_CHAIN_DEPTH (30).
10
10
  *
11
11
  * 2. Rate throttle — within a sliding window (WINDOW_MS = 5000, default
12
12
  * MAX_HITS_PER_WINDOW = 8), a single (sessionId, vpId) target may be
@@ -43,7 +43,7 @@
43
43
  * advance the rate counter.
44
44
  */
45
45
 
46
- export const MAX_CHAIN_DEPTH = 10;
46
+ export const MAX_CHAIN_DEPTH = 30;
47
47
  export const DEFAULT_WINDOW_MS = 5_000;
48
48
  export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
49
49
  export const DEFAULT_MAX_KEYS = 1_000;
@@ -137,7 +137,7 @@ export function createLoopGuard(options = {}) {
137
137
  if (!sessionId || !targetVpId) {
138
138
  return { ok: false, reason: 'chain_depth_exceeded', detail: { missing: true } };
139
139
  }
140
- if (Array.isArray(chain) && chain.length >= maxChainDepth) {
140
+ if (Array.isArray(chain) && chain.length > maxChainDepth) {
141
141
  return {
142
142
  ok: false,
143
143
  reason: 'chain_depth_exceeded',
@@ -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;
@@ -8,12 +8,21 @@
8
8
  import { defineTool } from './types.js';
9
9
  import { spawn } from 'child_process';
10
10
  import { readdir, readFile, stat } from 'fs/promises';
11
+ import { StringDecoder } from 'string_decoder';
11
12
  import { existsSync } from 'fs';
12
13
  import { resolve, join, relative, extname } from 'path';
13
14
 
14
15
  /** Max output lines. */
15
16
  const MAX_LINES = 250;
16
17
 
18
+ /** Hard cap before Grep output reaches history, debug events, or WebSocket. */
19
+ const MAX_OUTPUT_BYTES = 512 * 1024;
20
+ const OUTPUT_TRUNCATED_MARKER = '\n\n[Output truncated]';
21
+ const MAX_CAPTURE_BYTES = MAX_OUTPUT_BYTES - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
22
+
23
+ /** Keep one pathological source line from consuming the whole output budget. */
24
+ const MAX_LINE_BYTES = 16 * 1024;
25
+
17
26
  /** Binary extensions to skip. */
18
27
  const BINARY_EXTS = new Set([
19
28
  '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',
@@ -25,6 +34,77 @@ const BINARY_EXTS = new Set([
25
34
  '.sqlite', '.db',
26
35
  ]);
27
36
 
37
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
38
+
39
+ function decodeTextFile(buffer) {
40
+ // Extension lists are only a fast path. Generated artifacts and renamed
41
+ // binaries commonly have no useful extension, especially on Windows.
42
+ if (buffer.includes(0)) return null;
43
+ try { return utf8Decoder.decode(buffer); } catch { return null; }
44
+ }
45
+
46
+ function truncateUtf8(text, maxBytes) {
47
+ if (maxBytes <= 0) return '';
48
+ const buffer = Buffer.from(text, 'utf8');
49
+ if (buffer.length <= maxBytes) return text;
50
+ return new StringDecoder('utf8').write(buffer.subarray(0, maxBytes));
51
+ }
52
+
53
+ function boundToolOutput(text) {
54
+ if (Buffer.byteLength(text, 'utf8') <= MAX_OUTPUT_BYTES) return text;
55
+ return truncateUtf8(text, MAX_CAPTURE_BYTES) + OUTPUT_TRUNCATED_MARKER;
56
+ }
57
+
58
+ function formatGrepError(message) {
59
+ const errorMessage = `Grep failed: ${message}`;
60
+ const serialized = JSON.stringify({ error: errorMessage });
61
+ if (Buffer.byteLength(serialized, 'utf8') <= MAX_OUTPUT_BYTES) return serialized;
62
+
63
+ const markerBytes = Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
64
+ let low = 0;
65
+ let high = Math.max(0, Buffer.byteLength(errorMessage, 'utf8') - markerBytes);
66
+ let result = JSON.stringify({ error: OUTPUT_TRUNCATED_MARKER });
67
+
68
+ while (low <= high) {
69
+ const mid = Math.floor((low + high) / 2);
70
+ const candidate = JSON.stringify({
71
+ error: truncateUtf8(errorMessage, mid) + OUTPUT_TRUNCATED_MARKER,
72
+ });
73
+ if (Buffer.byteLength(candidate, 'utf8') <= MAX_OUTPUT_BYTES) {
74
+ result = candidate;
75
+ low = mid + 1;
76
+ } else {
77
+ high = mid - 1;
78
+ }
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ function createOutputCollector(maxBytes = MAX_OUTPUT_BYTES) {
85
+ const parts = [];
86
+ const contentBytes = Math.max(0, maxBytes - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8'));
87
+ let bytes = 0;
88
+ let truncated = false;
89
+ return {
90
+ add(value) {
91
+ if (truncated) return false;
92
+ const normalized = String(value).replace(/\r/g, '');
93
+ const line = truncateUtf8(normalized, MAX_LINE_BYTES);
94
+ const lineWasTruncated = Buffer.byteLength(normalized, 'utf8') > Buffer.byteLength(line, 'utf8');
95
+ const separator = parts.length > 0 ? '\n' : '';
96
+ const remaining = contentBytes - bytes - Buffer.byteLength(separator, 'utf8');
97
+ if (remaining <= 0) { truncated = true; return false; }
98
+ const bounded = truncateUtf8(line, remaining);
99
+ parts.push(separator + bounded);
100
+ bytes += Buffer.byteLength(separator + bounded, 'utf8');
101
+ if (lineWasTruncated || bounded !== line) truncated = true;
102
+ return !truncated;
103
+ },
104
+ toString() { return parts.join('') + (truncated ? OUTPUT_TRUNCATED_MARKER : ''); },
105
+ };
106
+ }
107
+
28
108
  /**
29
109
  * Check if ripgrep is available.
30
110
  */
@@ -39,7 +119,7 @@ function hasRipgrep() {
39
119
  /**
40
120
  * Run ripgrep and return results.
41
121
  */
42
- function runRipgrep(pattern, searchPath, options) {
122
+ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
43
123
  return new Promise((resolve, reject) => {
44
124
  const args = [
45
125
  pattern,
@@ -60,44 +140,72 @@ function runRipgrep(pattern, searchPath, options) {
60
140
  if (options.multiline) args.push('-U', '--multiline-dotall');
61
141
  args.push('--max-count', String(options.maxResults || 500));
62
142
 
63
- const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
64
- let stdout = '';
65
- let stderr = '';
66
-
67
- proc.stdout.on('data', (chunk) => {
68
- stdout += chunk.toString();
69
- // Truncate early if way too large
70
- if (stdout.length > 512 * 1024) {
143
+ const proc = spawnProcess('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
144
+ const stdoutChunks = [];
145
+ const stderrChunks = [];
146
+ let capturedBytes = 0;
147
+ let truncatedStream = null;
148
+ let settled = false;
149
+
150
+ function capture(streamName, chunk, chunks) {
151
+ if (truncatedStream) return;
152
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
153
+ const remaining = MAX_CAPTURE_BYTES - capturedBytes;
154
+ if (buffer.length > remaining) {
155
+ if (remaining > 0) chunks.push(buffer.subarray(0, remaining));
156
+ capturedBytes = MAX_CAPTURE_BYTES;
157
+ truncatedStream = streamName;
71
158
  try { proc.kill(); } catch {}
159
+ return;
72
160
  }
73
- });
74
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
161
+ chunks.push(buffer);
162
+ capturedBytes += buffer.length;
163
+ }
164
+
165
+ function decodeCaptured(chunks, wasTruncated) {
166
+ // Buffer decoding normally expands each invalid byte to the three-byte
167
+ // U+FFFD replacement character. Use a one-byte replacement, then enforce
168
+ // the final encoded-byte boundary as a last line of defense.
169
+ const marker = wasTruncated ? OUTPUT_TRUNCATED_MARKER : '';
170
+ const maxTextBytes = MAX_OUTPUT_BYTES - Buffer.byteLength(marker, 'utf8');
171
+ const decoded = Buffer.concat(chunks).toString('utf8').replaceAll('\ufffd', '?').replace(/\r/g, '');
172
+ return truncateUtf8(decoded, maxTextBytes) + marker;
173
+ }
174
+
175
+ proc.stdout.on('data', (chunk) => capture('stdout', chunk, stdoutChunks));
176
+ proc.stderr.on('data', (chunk) => capture('stderr', chunk, stderrChunks));
75
177
  proc.on('close', (code) => {
76
- if (code === 0 || code === 1) {
77
- resolve(stdout);
78
- } else {
79
- reject(new Error(stderr || `rg exited with code ${code}`));
80
- }
178
+ if (settled) return;
179
+ settled = true;
180
+ const stdout = decodeCaptured(stdoutChunks, truncatedStream === 'stdout');
181
+ const stderr = decodeCaptured(stderrChunks, truncatedStream === 'stderr');
182
+ if (code === 0 || code === 1 || truncatedStream === 'stdout') resolve(stdout);
183
+ else reject(new Error(stderr || `rg exited with code ${code}`));
184
+ });
185
+ proc.on('error', (err) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ reject(err);
81
189
  });
82
- proc.on('error', reject);
83
190
  });
84
191
  }
85
192
 
86
193
  /**
87
194
  * Fallback: Node.js grep implementation.
88
195
  */
89
- async function nodeGrep(pattern, searchPath, options) {
196
+ export async function nodeGrep(pattern, searchPath, options) {
90
197
  const regex = new RegExp(pattern, options.caseInsensitive ? 'gi' : 'g');
91
- const results = [];
198
+ const output = createOutputCollector();
199
+ let resultCount = 0;
92
200
  const SKIP = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
93
201
 
94
202
  async function searchDir(dir) {
95
- if (results.length >= (options.maxResults || 500)) return;
203
+ if (resultCount >= (options.maxResults || 500)) return;
96
204
  let entries;
97
205
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
98
206
 
99
207
  for (const entry of entries) {
100
- if (results.length >= (options.maxResults || 500)) return;
208
+ if (resultCount >= (options.maxResults || 500)) return;
101
209
  const fullPath = join(dir, entry.name);
102
210
 
103
211
  if (entry.isDirectory()) {
@@ -111,22 +219,32 @@ async function nodeGrep(pattern, searchPath, options) {
111
219
  const fileStat = await stat(fullPath);
112
220
  if (fileStat.size > 1024 * 1024) continue; // skip files > 1MB
113
221
 
114
- const content = await readFile(fullPath, 'utf-8');
222
+ const buffer = await readFile(fullPath);
223
+ const content = decodeTextFile(buffer);
224
+ if (content == null) continue;
115
225
  const relPath = relative(searchPath, fullPath);
116
226
 
117
227
  if (options.filesOnly) {
118
- if (regex.test(content)) results.push(relPath);
228
+ if (regex.test(content)) {
229
+ resultCount += 1;
230
+ if (!output.add(relPath)) return;
231
+ }
119
232
  regex.lastIndex = 0;
120
233
  } else if (options.count) {
121
234
  const matches = content.match(regex);
122
- if (matches) results.push(`${relPath}:${matches.length}`);
235
+ if (matches) {
236
+ resultCount += 1;
237
+ if (!output.add(`${relPath}:${matches.length}`)) return;
238
+ }
123
239
  } else {
124
240
  const lines = content.split('\n');
125
241
  for (let i = 0; i < lines.length; i++) {
126
242
  if (regex.test(lines[i])) {
127
- results.push(`${relPath}:${i + 1}:${lines[i]}`);
243
+ resultCount += 1;
244
+ if (!output.add(`${relPath}:${i + 1}:${lines[i]}`)) return;
128
245
  }
129
246
  regex.lastIndex = 0;
247
+ if (resultCount >= (options.maxResults || 500)) return;
130
248
  }
131
249
  }
132
250
  } catch {
@@ -137,7 +255,7 @@ async function nodeGrep(pattern, searchPath, options) {
137
255
  }
138
256
 
139
257
  await searchDir(searchPath);
140
- return results.join('\n');
258
+ return output.toString();
141
259
  }
142
260
 
143
261
  export default defineTool({
@@ -302,15 +420,18 @@ Guidelines:
302
420
  return '(no matches)';
303
421
  }
304
422
 
305
- // Limit output lines
423
+ // Limit output lines, then enforce the byte budget at the actual tool
424
+ // boundary so prefixes, JSON escaping, and result markers are included.
306
425
  const lines = result.trim().split('\n');
307
426
  if (lines.length > head_limit) {
308
- return lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`;
427
+ return boundToolOutput(
428
+ lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`,
429
+ );
309
430
  }
310
431
 
311
- return result.trim();
432
+ return boundToolOutput(result.trim());
312
433
  } catch (err) {
313
- return JSON.stringify({ error: `Grep failed: ${err.message}` });
434
+ return formatGrepError(err.message);
314
435
  }
315
436
  },
316
437
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { defineTool } from './types.js';
8
+ import { downloadRemoteImage } from '../remote-image-download.js';
8
9
 
9
10
  export default defineTool({
10
11
  name: 'ImageGeneration',
@@ -83,29 +84,26 @@ Guidelines:
83
84
 
84
85
  const data = await response.json();
85
86
 
86
- // If output_path specified, save the image
87
- if (output_path && data.url) {
87
+ if (!data.url) return JSON.stringify({ error: 'Image API returned no image URL' });
88
+ const { buffer, mimeType } = await downloadRemoteImage(data.url, {
89
+ signal: ctx?.signal,
90
+ ...(ctx?.remoteImageDownload || {}),
91
+ });
92
+ let savedPath = null;
93
+ if (output_path) {
88
94
  const { resolve: resolvePath } = await import('path');
89
95
  const { writeFile } = await import('fs/promises');
90
-
91
- const imgResponse = await fetch(data.url);
92
- const buffer = Buffer.from(await imgResponse.arrayBuffer());
93
- const absPath = resolvePath(ctx?.cwd || process.cwd(), output_path);
94
- await writeFile(absPath, buffer);
95
-
96
- return JSON.stringify({
97
- success: true,
98
- path: absPath,
99
- size,
100
- prompt: prompt.slice(0, 100),
101
- });
96
+ savedPath = resolvePath(ctx?.cwd || process.cwd(), output_path);
97
+ await writeFile(savedPath, buffer);
102
98
  }
103
-
104
99
  return JSON.stringify({
105
100
  success: true,
106
- url: data.url,
101
+ ...(savedPath ? { path: savedPath } : {}),
107
102
  size,
108
103
  prompt: prompt.slice(0, 100),
104
+ image: `data:${mimeType};base64,${buffer.toString('base64')}`,
105
+ mimeType,
106
+ filename: savedPath ? savedPath.split(/[/\\]/).pop() : `generated-${Date.now()}`,
109
107
  });
110
108
  } catch (err) {
111
109
  if (err.name === 'AbortError') return JSON.stringify({ error: 'Generation cancelled' });
@@ -23,6 +23,7 @@
23
23
  * `{ ok: false, error }` so the VP can pivot (apologise, retry, ...).
24
24
  */
25
25
 
26
+ import { MAX_CHAIN_DEPTH } from '../routing/loop-guard.js';
26
27
  import { defineTool } from './types.js';
27
28
 
28
29
  export default defineTool({
@@ -50,7 +51,7 @@ Arguments:
50
51
  Rules:
51
52
  - Forwarding to yourself is rejected (self_forward_rejected).
52
53
  - Forwarding to a non-member is rejected (target_not_in_roster).
53
- - Forwards carry a causedBy chain; chains deeper than 10 hops are blocked
54
+ - Forwards carry a causedBy chain; chains deeper than ${MAX_CHAIN_DEPTH} hops are blocked
54
55
  (chain_depth_exceeded).
55
56
  - A single target may be forwarded to at most 8 times per 5-second window
56
57
  per session (throttled).
@@ -69,7 +70,7 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
69
70
  规则:
70
71
  - 转发给自己会被拒绝(self_forward_rejected)。
71
72
  - 转发给非成员会被拒绝(target_not_in_roster)。
72
- - 转发带有 causedBy 链;超过 10 跳的链会被阻止(chain_depth_exceeded)。
73
+ - 转发带有 causedBy 链;超过 ${MAX_CHAIN_DEPTH} 跳的链会被阻止(chain_depth_exceeded)。
73
74
  - 同一目标在每 5 秒窗口内最多被转发 8 次(节流限制)。
74
75
 
75
76
  返回 JSON:{ ok, dispatched?, error?, detail? }。`
@@ -63,6 +63,7 @@ import {
63
63
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
64
64
  import { ConversationStore, parseSeqFromId } from './conversation/persist.js';
65
65
  import { isHiddenConversationRow } from './conversation/internal-control.js';
66
+ import { imageMetadataForPersistence } from './image-assets.js';
66
67
  import { sliceLastNTurns } from './turn-utils.js';
67
68
  import { pairSanitize } from './pair-sanitize.js';
68
69
  import { filterSnapshotForVp } from './snapshot-filter.js';
@@ -999,6 +1000,7 @@ function projectPersistedToHistoryEntry(m) {
999
1000
  if (m.id) entry.id = m.id;
1000
1001
  entry.threadId = m.threadId || m.turnId || 'main';
1001
1002
  if (m.turnId) entry.turnId = m.turnId;
1003
+ if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
1002
1004
  if (m.sessionId) entry.sessionId = m.sessionId;
1003
1005
  if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
1004
1006
  if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
@@ -1016,8 +1018,9 @@ function projectPersistedToHistoryEntry(m) {
1016
1018
  if (m.isError) entry.isError = true;
1017
1019
  if (m.ts) entry.ts = m.ts;
1018
1020
  else if (m.time) entry.ts = m.time;
1021
+ if (Array.isArray(m.images) && m.images.length > 0) entry.images = m.images;
1019
1022
  if (Array.isArray(m.attachments) && m.attachments.length > 0) entry.attachments = m.attachments;
1020
- if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.toolCalls && !entry.toolSummaryCount) return null;
1023
+ if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.toolSummaryCount) return null;
1021
1024
  return entry;
1022
1025
  }
1023
1026
 
@@ -1106,7 +1109,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1106
1109
  ...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
1107
1110
  threadId: m.threadId || m.turnId || 'main',
1108
1111
  ...(m.turnId ? { turnId: m.turnId } : {}),
1112
+ ...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
1109
1113
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
1114
+ ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
1110
1115
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
1111
1116
  ...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
1112
1117
  ? { toolSummaryCount: m.toolSummaryCount }
@@ -1156,7 +1161,13 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1156
1161
  if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
1157
1162
  sendSessionOutputFrame({
1158
1163
  type: 'assistant',
1159
- message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
1164
+ message: {
1165
+ id: entry.id || null,
1166
+ content: [
1167
+ ...(entry.content ? [{ type: 'text', text: entry.content }] : []),
1168
+ ...((entry.images || []).map(image => ({ type: 'image_asset', image }))),
1169
+ ],
1170
+ },
1160
1171
  ts: entry.ts || null,
1161
1172
  }, envelopeOpts);
1162
1173
  if (Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0) {
@@ -2851,6 +2862,7 @@ export function handleYeaftDeleteSession(msg) {
2851
2862
  try {
2852
2863
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2853
2864
  const result = deleteSession(yeaftDir, sessionId);
2865
+ ctx.assetOutbox?.removeSession(sessionId);
2854
2866
  // Cascade: remove every persisted message stamped with this group id.
2855
2867
  // Hard delete (per user spec): no soft-archive, the bytes are gone.
2856
2868
  // Skipped silently if the session/store isn't initialized — the next
@@ -3299,12 +3311,34 @@ function handleEngineEvent(event, hctx) {
3299
3311
  }, envelope);
3300
3312
  break;
3301
3313
 
3302
- case 'tool_end':
3314
+ case 'tool_end': {
3315
+ const images = Array.isArray(event.displayImages) ? event.displayImages : [];
3316
+ const displayOutput = typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? '');
3317
+ for (const image of images) {
3318
+ const persistedImage = imageMetadataForPersistence(image);
3319
+ try {
3320
+ if (!ctx.assetOutbox) throw new Error('asset outbox is unavailable');
3321
+ const deliveryId = ctx.assetOutbox.enqueue({
3322
+ conversationId: yeaftConversationId,
3323
+ metadata: persistedImage,
3324
+ sessionId: hctx.sessionId,
3325
+ vpId: hctx.vpId,
3326
+ turnId: hctx.turnId,
3327
+ threadId: hctx.threadId || event.threadId,
3328
+ image,
3329
+ });
3330
+ if (!deliveryId) throw new Error('asset outbox did not persist the image');
3331
+ image.deliveryQueued = true;
3332
+ ctx.assetOutbox.drain().catch(err => console.warn('[AssetOutbox] drain failed:', err?.message || err));
3333
+ } catch (err) {
3334
+ console.warn('[AssetOutbox] failed to queue image:', err?.message || err);
3335
+ }
3336
+ }
3303
3337
  if (hctx.toolResultsAccum) {
3304
3338
  hctx.toolResultsAccum.push({
3305
3339
  role: 'tool',
3306
3340
  toolCallId: event.id,
3307
- content: typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? ''),
3341
+ content: displayOutput,
3308
3342
  isError: !!event.isError,
3309
3343
  });
3310
3344
  }
@@ -3330,7 +3364,7 @@ function handleEngineEvent(event, hctx) {
3330
3364
  tool_use_result: [{
3331
3365
  type: 'tool_result',
3332
3366
  tool_use_id: event.id,
3333
- content: event.output || '',
3367
+ content: displayOutput,
3334
3368
  is_error: event.isError || false,
3335
3369
  }],
3336
3370
  }, envelope);
@@ -3341,6 +3375,7 @@ function handleEngineEvent(event, hctx) {
3341
3375
  // streaming' flicker on every tool call. Hold the 'tool' state
3342
3376
  // until the next real event arrives.
3343
3377
  break;
3378
+ }
3344
3379
 
3345
3380
  case 'tool_result_update': {
3346
3381
  const content = typeof event.content === 'string'
@@ -6613,6 +6648,7 @@ export async function handleYeaftMcpReload(msg = {}) {
6613
6648
 
6614
6649
  export const __testHooks = {
6615
6650
  loadVisibleGroupHistoryPage,
6651
+ projectVisibleHistoryChunkMessages,
6616
6652
  persistInboundMessageOnceByMsgId,
6617
6653
  buildPendingRescueEnvelope,
6618
6654
  runYeaftSessionSendForTest(msg) {