@yeaft/webchat-agent 1.0.589 → 1.0.591

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.
@@ -0,0 +1,77 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { getQuickJS } from 'quickjs-emscripten';
3
+
4
+ // Separate WASM interpreter, NOT node:vm. No host functions/modules are bound.
5
+ // Parent termination bounds startup, computation, output extraction and cleanup.
6
+ let runtime, context;
7
+ try {
8
+ const engine = await getQuickJS();
9
+ runtime = engine.newRuntime();
10
+ runtime.setMemoryLimit(16 * 1024 * 1024);
11
+ runtime.setMaxStackSize(256 * 1024);
12
+ const deadline = Date.now() + 200;
13
+ runtime.setInterruptHandler(() => Date.now() > deadline);
14
+ context = runtime.newContext();
15
+ const { code, input } = workerData;
16
+ // Capture intrinsics before executing generated code; Function compiles ONLY a
17
+ // function body and has no access to this validation closure. Neither raw code
18
+ // interpolation nor mutable guest serializers can redefine the output boundary.
19
+ const source = `(() => {
20
+ 'use strict';
21
+ const compile = Function, parse = JSON.parse, stringify = JSON.stringify;
22
+ const isArray = Array.isArray, finite = Number.isFinite;
23
+ const proto = Object.getPrototypeOf, objectProto = Object.prototype;
24
+ const descriptor = Object.getOwnPropertyDescriptor, keys = Reflect.ownKeys;
25
+ const create = Object.create, setProto = Object.setPrototypeOf, ErrorType = Error;
26
+ const input = parse(${JSON.stringify(JSON.stringify(input))});
27
+ const fn = compile('input', ${JSON.stringify(`'use strict';\n${code}`)});
28
+ const ancestors = create(null);
29
+ let nodes = 0;
30
+ function snapshot(v, depth) {
31
+ if (++nodes > 8192 || depth > 32) throw new ErrorType('JSON limit');
32
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return v;
33
+ if (typeof v === 'number' && finite(v)) return v;
34
+ if (typeof v !== 'object') throw new ErrorType('JSON only');
35
+ for (let i = 0; i < depth; i++) if (ancestors[i] === v) throw new ErrorType('JSON cycle');
36
+ const array = isArray(v), p = proto(v);
37
+ if (!array && p !== objectProto && p !== null) throw new ErrorType('JSON object only');
38
+ const names = keys(v);
39
+ const length = array ? descriptor(v, 'length').value : names.length;
40
+ if (length > 8192 || names.length !== length + (array ? 1 : 0)) throw new ErrorType('JSON keys');
41
+ const out = array ? setProto([], null) : create(null);
42
+ ancestors[depth] = v;
43
+ for (let i = 0; i < length; i++) {
44
+ const key = array ? '' + i : names[i];
45
+ if (typeof key !== 'string') throw new ErrorType('JSON key');
46
+ const d = descriptor(v, key);
47
+ if (!d || !d.enumerable || !descriptor(d, 'value')) throw new ErrorType('JSON data only');
48
+ out[key] = snapshot(d.value, depth + 1);
49
+ }
50
+ delete ancestors[depth];
51
+ return out;
52
+ }
53
+ const value = snapshot(fn(input), 0);
54
+ // Snapshot has null prototypes and data properties only: no guest getters or
55
+ // inherited toJSON are run during serialization, even after global mutation.
56
+ const json = stringify(value);
57
+ if (typeof json !== 'string' || json.length > 8192) throw new ErrorType('JSON output limit');
58
+ return json;
59
+ })()`;
60
+ const result = context.evalCode(source, 'created-capability.js');
61
+ if (result.error) {
62
+ result.error.dispose();
63
+ parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
64
+ } else {
65
+ const json = context.getString(result.value);
66
+ result.value.dispose();
67
+ if (Buffer.byteLength(json, 'utf8') > 8192) parentPort.postMessage({ ok: false, code: 'SCRIPT_OUTPUT' });
68
+ else parentPort.postMessage({ ok: true, output: JSON.parse(json) });
69
+ }
70
+ } catch {
71
+ // Guest error strings may contain arbitrary input; fixed codes only cross out.
72
+ parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
73
+ } finally {
74
+ context?.dispose();
75
+ runtime?.dispose();
76
+ parentPort.close();
77
+ }
@@ -1,6 +1,7 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { digest, fail, identifier, PersonError, text } from './contracts.js';
4
+ import { validateCreatedCapability } from './created-capability-contract.js';
4
5
 
5
6
  const storageError = code => {
6
7
  const error = new PersonError(code);
@@ -91,6 +92,8 @@ export class SqlitePersonRepository {
91
92
  async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
92
93
  async startCall(episode, data) { return this.request('startCall', [episode, data]); }
93
94
  async finalizeCall(episode, data) { return this.request('finalizeCall', [episode, data]); }
95
+ async createdCapabilities(episode) { return this.request('createdCapabilities', [episode]); }
96
+ async saveCreatedCapability(episode, input) { return this.request('saveCreatedCapability', [episode, validateCreatedCapability(input)]); }
94
97
  async context(episode) { return this.request('context', [episode]); }
95
98
  async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
96
99
  async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
@@ -8,6 +8,8 @@ export const TABLES = Object.freeze({
8
8
  concept_revisions: { keys: ['id', 'revision'], columns: [] },
9
9
  state_commits: { keys: ['version'], columns: ['id'] },
10
10
  traces: { keys: ['id'], columns: ['seq'] },
11
+ created_capabilities: { keys: ['id'], columns: ['version'] },
12
+ created_capability_revisions: { keys: ['id', 'version'], columns: [] },
11
13
  });
12
14
 
13
15
  export const SCHEMA = `
@@ -66,6 +68,21 @@ CREATE TABLE IF NOT EXISTS traces (
66
68
  PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
67
69
  FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
68
70
  ) STRICT;
71
+ CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
72
+ json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
73
+ WHERE json_extract(record, '$.kind') = 'call_output';
74
+ CREATE TABLE IF NOT EXISTS created_capabilities (
75
+ namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
76
+ id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
77
+ record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
78
+ FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
79
+ ) STRICT;
80
+ CREATE TABLE IF NOT EXISTS created_capability_revisions (
81
+ namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
82
+ id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
83
+ record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id, version),
84
+ FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
85
+ ) STRICT;
69
86
  -- This journal is authority data, not a disposable search index. A committed seq is
70
87
  -- never reused, including after reopening; gaps between owners are intentional.
71
88
  CREATE TABLE IF NOT EXISTS memory_changes (
@@ -4,6 +4,7 @@ import { dirname } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { isMainThread } from 'node:worker_threads';
6
6
  import { bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
7
+ import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
7
8
  import { SCHEMA, TABLES } from './sqlite-schema.js';
8
9
  import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
9
10
 
@@ -11,8 +12,20 @@ const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
11
12
  const scopeValues = s => [s.namespace, s.ownerId, s.personId];
12
13
  // Query-only projections; record retains the original public Unicode text.
13
14
  const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
14
- const dates = new Set(['createdAt', 'updatedAt', 'endedAt', 'leaseUntil', 'callFinalizeUntil']);
15
- const decode = row => row ? JSON.parse(row.record, (key, value) => dates.has(key) && typeof value === 'string' ? new Date(value) : value) : null;
15
+ const metadataDates = Object.freeze({
16
+ persons: ['createdAt', 'leaseUntil'], episodes: ['createdAt', 'endedAt', 'callFinalizeUntil'],
17
+ messages: ['createdAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
18
+ state_commits: ['createdAt'], traces: ['createdAt'],
19
+ });
20
+ const decode = (row, table) => {
21
+ if (!row) return null;
22
+ const record = JSON.parse(row.record);
23
+ // Only known storage metadata is a Date. Trace payloads and created-definition
24
+ // JSON may use these names at any depth without changing their string values.
25
+ for (const key of metadataDates[table] ?? []) if (typeof record[key] === 'string') record[key] = new Date(record[key]);
26
+ if (table === 'state_commits' && typeof record.state?.updatedAt === 'string') record.state.updatedAt = new Date(record.state.updatedAt);
27
+ return record;
28
+ };
16
29
  const publicDoc = doc => {
17
30
  if (!doc) return null;
18
31
  const { _id, ownerId, namespace, personId, ...rest } = doc;
@@ -32,8 +45,8 @@ const publicOutput = (output, failed) => {
32
45
  return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
33
46
  : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
34
47
  };
35
- const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories']);
36
- const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot']);
48
+ const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities']);
49
+ const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
37
50
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
38
51
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
39
52
  const sequence = value => {
@@ -101,7 +114,8 @@ export class SqlitePersonStore {
101
114
  // All clauses at call sites below are fixed application strings; values are bound.
102
115
  rows(table, scope, clause = '', params = []) {
103
116
  this.table(table);
104
- return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params).map(decode);
117
+ return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params)
118
+ .map(row => decode(row, table));
105
119
  }
106
120
  one(table, scope, clause = '', params = []) { return this.rows(table, scope, `${clause} LIMIT 1`, params)[0] ?? null; }
107
121
  put(table, record, insert = false) {
@@ -220,6 +234,8 @@ export class SqlitePersonStore {
220
234
  const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
221
235
  }
222
236
  append(episode, kind, data) {
237
+ // Call proof/publication events are emitted only by their transactional methods.
238
+ if (['call_started', 'call_output', 'call_failed', 'capability_created'].includes(kind)) fail('INVALID_REQUEST');
223
239
  const p = this.own(episode);
224
240
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
225
241
  if (!record || record.status !== 'running') fail('STALE');
@@ -255,6 +271,28 @@ export class SqlitePersonStore {
255
271
  });
256
272
  return live;
257
273
  }
274
+ createdCapabilities(episode) {
275
+ this.own(episode);
276
+ return this.rows('created_capabilities', this.episodeScope(episode), ' ORDER BY id ASC LIMIT 32').map(publicDoc);
277
+ }
278
+ saveCreatedCapability(episode, input) {
279
+ const { definition, evidence, callId } = validateCreatedCapability(input);
280
+ this.own(episode);
281
+ const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
282
+ if (!active || active.status !== 'running' || active.epoch !== episode.epoch || active.workerId !== episode.workerId) fail('STALE');
283
+ const proof = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
284
+ if (!proof || active.openCall?.callId === callId) fail('INVALID_REQUEST');
285
+ const current = this.one('created_capabilities', scope, ' AND id = ?', [definition.id]);
286
+ if ((current?.version ?? 0) !== definition.expectedVersion) fail('STALE');
287
+ if (definition.expectedVersion >= CREATED_CAPABILITY_LIMITS.versions || (!current &&
288
+ this.sql(`SELECT count(*) AS n FROM created_capabilities WHERE ${SCOPE}`).get(...scopeValues(scope)).n >= CREATED_CAPABILITY_LIMITS.ids)) fail('CONTEXT_LIMIT');
289
+ const record = this.doc(scope, createdCapabilityRecord(definition, evidence, { episode, callId, now: this.now }));
290
+ this.put('created_capability_revisions', record, true);
291
+ this.put('created_capabilities', record);
292
+ this.trace(episode.ownerId, episode.id, 'capability_created', { callId, capabilityId: record.id,
293
+ capabilityManifest: { id: record.id, version: record.version, revision: record.revision }, evidence: record.evidence });
294
+ return publicDoc(record);
295
+ }
258
296
  focused(scope, ids) {
259
297
  return ids.map(id => this.one('concepts', scope, ' AND id = ?', [id])).filter(Boolean);
260
298
  }
@@ -366,7 +404,7 @@ export class SqlitePersonStore {
366
404
  this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);
367
405
  const rows = this.sql(`SELECT seq, kind, id, revision, record FROM memory_changes WHERE ${SCOPE} AND seq > ? ORDER BY seq ASC LIMIT ?`)
368
406
  .all(...scopeValues(this.scope(ownerId)), after, limit + 1);
369
- const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row) }));
407
+ const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row, row.kind) }));
370
408
  return { items, lastSeq: items.at(-1)?.seq ?? after, hasMore: rows.length > limit };
371
409
  }
372
410
  resolveMemories(ownerId, kind, refs) {
@@ -125,6 +125,16 @@ export function buildDuplicateReminder({ toolName, count, lastResultBrief }) {
125
125
  + `Consider whether re-running this tool is necessary or if you should try a different approach.`;
126
126
  }
127
127
 
128
+ function toolOutputForReflection(content) {
129
+ if (typeof content === 'string') return content;
130
+ if (Array.isArray(content) && content.every(part => part && ['text', 'image', 'document'].includes(part.type))) {
131
+ // Reflection consumes text, not a base64 dump of visual context. The image
132
+ // has already been delivered to the primary model in the paired result.
133
+ return content.map(part => part.type === 'text' ? part.text : `[${part.type} content omitted from reflection]`).join('\n');
134
+ }
135
+ return JSON.stringify(content);
136
+ }
137
+
128
138
  /**
129
139
  * Convert assistant.toolCalls + matching tool results into the
130
140
  * { name, input, output, isError } pairs the reflector prompt expects.
@@ -159,14 +169,14 @@ export function extractToolPairsFromRange(messages, startIdx, endIdx) {
159
169
  } else if (m.role === 'tool') {
160
170
  const ent = m.toolCallId ? byId.get(m.toolCallId) : null;
161
171
  if (ent) {
162
- ent.output = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
172
+ ent.output = toolOutputForReflection(m.content);
163
173
  ent.isError = !!m.isError;
164
174
  } else {
165
175
  // Orphan tool result — still include for completeness.
166
176
  pairs.push({
167
177
  name: '(orphan)',
168
178
  input: {},
169
- output: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
179
+ output: toolOutputForReflection(m.content),
170
180
  isError: !!m.isError,
171
181
  });
172
182
  }
@@ -238,6 +238,17 @@ function truncateUtf8(text, maxBytes) {
238
238
  }
239
239
 
240
240
  export function truncateToolResultIfNeeded(output, { toolName, language } = {}) {
241
+ // Provider content parts are not JSON text. Bound their text together while
242
+ // preserving binary blocks; history-window budgets/omits attachments whole.
243
+ // Arbitrary structured tool output still follows the legacy JSON path.
244
+ if (Array.isArray(output) && output.length > 0
245
+ && output.every(part => part && ['text', 'image', 'document'].includes(part.type))) {
246
+ const text = output.filter(part => part.type === 'text').map(part => part.text || '').join('\n');
247
+ return [
248
+ ...(text ? [{ type: 'text', text: truncateToolResultIfNeeded(text, { toolName, language }) }] : []),
249
+ ...output.filter(part => part.type !== 'text'),
250
+ ];
251
+ }
241
252
  const text = normalizeToolOutput(output);
242
253
  const originalBytes = Buffer.byteLength(text, 'utf8');
243
254
  if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
@@ -123,8 +123,9 @@ export default defineTool({
123
123
  en: `Load a local image file and attach it to the conversation so the LLM can see it.
124
124
 
125
125
  Returns a base64 data URI (\`image\` field) plus metadata (format, dimensions,
126
- size). The caller/bridge is responsible for turning the data URI into the
127
- provider-specific image content block.
126
+ size). The engine delivers the image as a native multimodal tool result.
127
+ Inspect the whole image first. Crop/zoom only details that are unreadable; OCR
128
+ is optional for text extraction, not a prerequisite for seeing the image.
128
129
 
129
130
  When to call:
130
131
  - User references a local image path (screenshot, design, log/chart) and
@@ -151,8 +152,8 @@ Max size: 20 MiB by default (configurable via ctx.maxImageBytes).
151
152
  Path must live under the project directory or an explicit host allowlist.`,
152
153
  zh: `加载本地图片文件并附加到对话中,使 LLM 可以查看。
153
154
 
154
- 返回 base64 data URI(image 字段)及元数据(格式、尺寸、大小)。调用方/桥接层负责将 data URI 转换为
155
- provider 特定的图片内容块。
155
+ 返回 base64 data URI(image 字段)及元数据(格式、尺寸、大小)。引擎将图片作为原生多模态工具结果交给模型。
156
+ 先直接查看整图;仅对确实看不清的细节裁剪/放大。OCR 用于需要时提取文字,不是看图的前置步骤。
156
157
 
157
158
  何时调用:
158
159
  - 用户引用本地图片路径(截图、设计稿、日志/图表)并要求你读取、分析或描述它。
@@ -1454,12 +1454,15 @@ function projectPersistedToVisibleHistoryEntry(m) {
1454
1454
  return entry && (entry.role === 'user' || entry.role === 'assistant') ? entry : null;
1455
1455
  }
1456
1456
 
1457
- function hydrateHistoryAttachmentPreviews(attachments) {
1457
+ function hydrateHistoryAttachmentPreviews(attachments, sessionId) {
1458
1458
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
1459
1459
  return attachments.map((att) => {
1460
1460
  if (!att || typeof att !== 'object') return att;
1461
1461
  if (!att.isImage || att.preview || att.previewData) return att;
1462
- const payload = persistedAttachmentPreviewPayload(att);
1462
+ const payload = sessionId ? persistedAttachmentPreviewPayload(att, {
1463
+ yeaftDir: session?.yeaftDir || ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR,
1464
+ sessionId,
1465
+ }) : null;
1463
1466
  return payload ? { ...att, previewData: payload } : att;
1464
1467
  });
1465
1468
  }
@@ -1538,7 +1541,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1538
1541
  threadId: m.threadId || m.turnId || 'main',
1539
1542
  ...(m.turnId ? { turnId: m.turnId } : {}),
1540
1543
  ...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
1541
- ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
1544
+ ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments, m.sessionId) } : {}),
1542
1545
  ...(m.quote ? { quote: m.quote } : {}),
1543
1546
  ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
1544
1547
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
@@ -1596,7 +1599,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1596
1599
  message: {
1597
1600
  content: entry.content,
1598
1601
  id: entry.id || null,
1599
- ...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
1602
+ ...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments, entry.sessionId) } : {}),
1600
1603
  ...(entry.quote ? { quote: entry.quote } : {}),
1601
1604
  },
1602
1605
  ts: entry.ts || null,
@@ -4920,9 +4923,9 @@ async function runYeaftSessionSend(msg) {
4920
4923
  // ── Attachments (images + files) ───────────────────────────────
4921
4924
  // Server has already resolved fileId → { name, mimeType, data:base64,
4922
4925
  // isImage } via the client-conversation.js relay
4923
- // for `yeaft_*`). We persist files to disk under the agent's CWD so
4924
- // file-tools (file-read / bash) can pick them up with relative paths,
4925
- // and we build per-image content blocks for the LLM call. The
4926
+ // for `yeaft_*`). Session files belong to the instance data root, not
4927
+ // the agent CWD or project workDir. Absolute controlled references work
4928
+ // with file-tools; per-image content blocks go directly to the model. The
4926
4929
  // resolved metadata WITHOUT base64 rides on coord.ingest meta so it
4927
4930
  // shows up in the persisted group log and on the envelope every VP
4928
4931
  // driver receives.
@@ -4931,9 +4934,10 @@ async function runYeaftSessionSend(msg) {
4931
4934
  const attachmentsStart = perfNowMs();
4932
4935
  if (inboundFiles.length > 0) {
4933
4936
  try {
4934
- attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: sessionId });
4937
+ attachmentBundle = persistYeaftAttachments(inboundFiles, { yeaftDir, sessionId });
4935
4938
  } catch (err) {
4936
4939
  console.warn('[Yeaft] yeaft_session_chat: attachment persist failed', err?.message || err);
4940
+ attachmentBundle.failed = inboundFiles.map(file => ({ name: file?.name || '<unknown>', error: err?.message || String(err) }));
4937
4941
  }
4938
4942
  }
4939
4943
  // Surface partial / total upload failures to the user. We don't abort