@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.
- package/connection/message-router.js +7 -0
- package/context.js +1 -0
- package/index.js +5 -0
- package/package.json +1 -1
- package/yeaft/asset-outbox.js +124 -0
- package/yeaft/conversation/persist.js +14 -0
- package/yeaft/engine.js +11 -2
- package/yeaft/image-assets.js +91 -0
- package/yeaft/remote-image-download.js +199 -0
- package/yeaft/routing/loop-guard.js +3 -3
- package/yeaft/stop-hooks.js +12 -6
- package/yeaft/tools/grep.js +151 -30
- package/yeaft/tools/image-generation.js +14 -16
- package/yeaft/tools/route-forward.js +3 -2
- package/yeaft/web-bridge.js +41 -5
|
@@ -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
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
|
@@ -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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
140
|
+
if (Array.isArray(chain) && chain.length > maxChainDepth) {
|
|
141
141
|
return {
|
|
142
142
|
ok: false,
|
|
143
143
|
reason: 'chain_depth_exceeded',
|
package/yeaft/stop-hooks.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/yeaft/tools/grep.js
CHANGED
|
@@ -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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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))
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 链;超过
|
|
73
|
+
- 转发带有 causedBy 链;超过 ${MAX_CHAIN_DEPTH} 跳的链会被阻止(chain_depth_exceeded)。
|
|
73
74
|
- 同一目标在每 5 秒窗口内最多被转发 8 次(节流限制)。
|
|
74
75
|
|
|
75
76
|
返回 JSON:{ ok, dispatched?, error?, detail? }。`
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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: {
|
|
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:
|
|
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:
|
|
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) {
|