@yeaft/webchat-agent 1.0.155 → 1.0.157

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.155",
3
+ "version": "1.0.157",
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",
@@ -41,6 +41,7 @@ import {
41
41
  } from './adapter.js';
42
42
  import {
43
43
  normalizeEffort,
44
+ getModelEffortOptions,
44
45
  getThinkingCapability,
45
46
  mapEffortToOpenAIReasoning,
46
47
  } from '../models.js';
@@ -57,10 +58,9 @@ function thinkingV1Enabled() {
57
58
  }
58
59
 
59
60
  /**
60
- * Translate a normalised effort ('minimal'|'low'|'medium'|'high'|'xhigh'|'max') into the value
61
- * accepted by the OpenAI Responses `reasoning.effort` field. Responses today
62
- * accepts 'minimal'|'low'|'medium'|'high'|'xhigh'. Unsupported Anthropic-only
63
- * adaptive efforts must be dropped, not downgraded.
61
+ * Translate a normalised effort into the OpenAI Responses `reasoning.effort`
62
+ * field. Model capability filtering happens at the call site because `max` is
63
+ * supported by GPT-5.6 but not by older OpenAI reasoning models.
64
64
  */
65
65
  function effortForResponses(effort) {
66
66
  return mapEffortToOpenAIReasoning(effort);
@@ -248,7 +248,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
248
248
  // ─── Streaming ──────────────────────────────────────────
249
249
 
250
250
  /**
251
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
251
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void, onRequestStart?: () => void }} params
252
252
  *
253
253
  * NOTE on `extraBody`: any keys you spread here are merged verbatim into
254
254
  * the wire body and — because the verbatim debug feature is intentionally
@@ -257,7 +257,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
257
257
  * `api-key` headers are auto-redacted (see `redactRawRequest` in
258
258
  * `adapter.js`); request-body fields are caller-controlled.
259
259
  */
260
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, extraBody, signal, onRawExchange, onRequestStart }) {
260
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, signal, onRawExchange, onRequestStart }) {
261
261
  if (signal?.aborted) throw new LLMAbortError();
262
262
 
263
263
  const body = {
@@ -276,8 +276,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
276
276
  // Unknown / unsupported models silently drop the field.
277
277
  const normEffort = normalizeEffort(effort);
278
278
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
279
- const cap = getThinkingCapability(model);
280
- if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
279
+ const cap = getThinkingCapability(model, effortContext);
280
+ const supportedEfforts = getModelEffortOptions(model, effortContext);
281
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
281
282
  const wireEffort = effortForResponses(normEffort);
282
283
  if (wireEffort) body.reasoning = { effort: wireEffort };
283
284
  }
@@ -517,7 +518,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
517
518
  * expose them, mirror the stream() instrumentation. Parity with
518
519
  * anthropic.js's `call()`.
519
520
  */
520
- async call({ model, system, messages, maxTokens = 4096, effort, effortSource, extraBody, signal, onRequestStart }) {
521
+ async call({ model, system, messages, maxTokens = 4096, effort, effortSource, effortContext = {}, extraBody, signal, onRequestStart }) {
521
522
  if (signal?.aborted) throw new LLMAbortError();
522
523
 
523
524
  const body = {
@@ -530,8 +531,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
530
531
  // Mirror stream()'s thinking injection for non-streaming side queries.
531
532
  const normEffort = normalizeEffort(effort);
532
533
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
533
- const cap = getThinkingCapability(model);
534
- if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
534
+ const cap = getThinkingCapability(model, effortContext);
535
+ const supportedEfforts = getModelEffortOptions(model, effortContext);
536
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
535
537
  const wireEffort = effortForResponses(normEffort);
536
538
  if (wireEffort) body.reasoning = { effort: wireEffort };
537
539
  }
package/yeaft/models.js CHANGED
@@ -34,7 +34,7 @@ import { lookupModelLimitSync } from './llm/models-dev.js';
34
34
  * @property {'anthropic' | 'anthropic-adaptive' | 'openai-reasoning' | 'none'} [thinkingProtocol] — task-327a:
35
35
  * 'anthropic' → thinking:{type:'enabled', budget_tokens:N}
36
36
  * 'anthropic-adaptive' → thinking:{type:'adaptive'} + output_config:{effort}
37
- * 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'}
37
+ * 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'|'max'}
38
38
  * 'none' (default) → parameter silently dropped by router
39
39
  * @property {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null} [defaultEffort] — adapter-level default
40
40
  * when caller doesn't specify effort (null = no default / decision-tree decides).
@@ -405,12 +405,12 @@ export const ANTHROPIC_THINKING_BUDGETS = {
405
405
  };
406
406
 
407
407
  /**
408
- * Map a Yeaft effort level to the OpenAI reasoning.effort enum. Current
409
- * OpenAI APIs expose xhigh, but not Anthropic's 'max'. Unsupported values are
410
- * dropped by returning null; the adapter MUST NOT error.
408
+ * Map a Yeaft effort level to the OpenAI reasoning.effort enum. Supported
409
+ * values are model-dependent; capability filtering happens before this wire
410
+ * translation. Unsupported values are dropped; the adapter MUST NOT error.
411
411
  *
412
412
  * @param {Effort} effort
413
- * @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | null}
413
+ * @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null}
414
414
  */
415
415
  export function mapEffortToOpenAIReasoning(effort) {
416
416
  if (!effort) return null;
@@ -420,11 +420,13 @@ export function mapEffortToOpenAIReasoning(effort) {
420
420
  case 'medium': return 'medium';
421
421
  case 'high': return 'high';
422
422
  case 'xhigh': return 'xhigh';
423
+ case 'max': return 'max';
423
424
  default: return null;
424
425
  }
425
426
  }
426
427
 
427
428
  export const OPENAI_REASONING_EFFORT_OPTIONS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
429
+ export const OPENAI_MAX_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
428
430
  export const ANTHROPIC_MANUAL_EFFORT_OPTIONS = ['low', 'medium', 'high'];
429
431
  export const ANTHROPIC_ADAPTIVE_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
430
432
  export const ANTHROPIC_ADAPTIVE_MAX_EFFORT_OPTIONS = ['low', 'medium', 'high', 'max'];
@@ -454,6 +456,19 @@ function inferThinkingCapability(model) {
454
456
  const id = parseModelRef(model).modelId.toLowerCase();
455
457
  if (!id) return null;
456
458
 
459
+ // GPT-5.6 adds the model-specific `max` tier. Keep this ahead of the generic
460
+ // GPT inference so older OpenAI models do not advertise a value they reject.
461
+ // Provider suffixes such as gpt-5.6-sol are variants of the same model family.
462
+ if (/^gpt-5\.6($|[-.])/.test(id)) {
463
+ return {
464
+ supportsThinking: true,
465
+ thinkingProtocol: 'openai-reasoning',
466
+ defaultEffort: null,
467
+ maxBudgetTokens: null,
468
+ effortOptions: OPENAI_MAX_REASONING_EFFORT_OPTIONS,
469
+ };
470
+ }
471
+
457
472
  if (/^(gpt-5|o1|o3|o4|chatgpt-|codex-)/.test(id)) {
458
473
  return { supportsThinking: true, thinkingProtocol: 'openai-reasoning', defaultEffort: null, maxBudgetTokens: null };
459
474
  }
@@ -9,6 +9,7 @@ import {
9
9
  readFileSync,
10
10
  realpathSync,
11
11
  rmSync,
12
+ unlinkSync,
12
13
  writeFileSync,
13
14
  } from 'node:fs';
14
15
  import { createHash, randomUUID } from 'node:crypto';
@@ -175,6 +176,43 @@ function removeCreatedDirectory(state) {
175
176
  }
176
177
  }
177
178
 
179
+ function openAttachmentDirectory(root, workItemId) {
180
+ if (process.platform !== 'linux') {
181
+ throw new Error('Secure WorkItem attachment access requires Linux');
182
+ }
183
+ const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
184
+ const rootDescriptor = openDirectory(attachmentRoot, 'WorkItem attachment root');
185
+ try {
186
+ const ownerName = safeWorkItemId(workItemId);
187
+ const itemDescriptor = openDirectory(
188
+ `/proc/self/fd/${rootDescriptor}/${ownerName}`,
189
+ 'WorkItem attachment owner directory',
190
+ );
191
+ try {
192
+ assertDescriptorMatchesPath(rootDescriptor, attachmentRoot, 'WorkItem attachment root');
193
+ assertDescriptorMatchesPath(itemDescriptor, itemDirectory, 'WorkItem attachment owner directory');
194
+ return { attachmentRoot, itemDirectory, ownerName, rootDescriptor, itemDescriptor };
195
+ } catch (error) {
196
+ closeSync(itemDescriptor);
197
+ throw error;
198
+ }
199
+ } catch (error) {
200
+ closeSync(rootDescriptor);
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ function removeAttachmentFile(directoryState, storageName) {
206
+ if (!/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) return;
207
+ assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
208
+ assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
209
+ try {
210
+ unlinkSync(`/proc/self/fd/${directoryState.itemDescriptor}/${storageName}`);
211
+ } catch (error) {
212
+ if (error?.code !== 'ENOENT') throw error;
213
+ }
214
+ }
215
+
178
216
  export function persistWorkItemAttachments(files, options = {}) {
179
217
  if (!Array.isArray(files) || files.length === 0) return [];
180
218
  if (files.length > MAX_WORK_ITEM_ATTACHMENTS) {
@@ -226,6 +264,74 @@ export function persistWorkItemAttachments(files, options = {}) {
226
264
  }
227
265
  }
228
266
 
267
+ export function appendWorkItemAttachments(existing, files, options = {}) {
268
+ if (!Array.isArray(files) || files.length === 0) return [];
269
+ const current = Array.isArray(existing) ? existing : [];
270
+ if (current.length === 0) return persistWorkItemAttachments(files, options);
271
+ if (current.length + files.length > MAX_WORK_ITEM_ATTACHMENTS) {
272
+ throw new Error(`WorkItem supports at most ${MAX_WORK_ITEM_ATTACHMENTS} attachments`);
273
+ }
274
+
275
+ let totalBytes = current.reduce((total, attachment) => total + (Number(attachment?.size) || 0), 0);
276
+ const prepared = files.map((file, index) => {
277
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
278
+ throw new Error(`Attachment ${index + 1} is invalid`);
279
+ }
280
+ const name = safeDisplayName(file.name, current.length + index);
281
+ const mimeType = typeof file.mimeType === 'string' && file.mimeType.trim()
282
+ ? file.mimeType.trim().slice(0, 255)
283
+ : 'application/octet-stream';
284
+ const kind = assertSupportedWorkItemAttachment(name, mimeType);
285
+ const buffer = decodeBase64(file.data);
286
+ assertWorkItemAttachmentSize(buffer.length);
287
+ totalBytes += buffer.length;
288
+ if (totalBytes > MAX_WORK_ITEM_ATTACHMENT_BYTES) {
289
+ throw new Error(`WorkItem attachments exceed ${MAX_WORK_ITEM_ATTACHMENT_BYTES} bytes`);
290
+ }
291
+ const id = randomUUID();
292
+ return {
293
+ buffer,
294
+ attachment: {
295
+ id,
296
+ name,
297
+ storageName: `${id}${safeExtension(name)}`,
298
+ mimeType,
299
+ size: buffer.length,
300
+ sha256: digest(buffer),
301
+ kind,
302
+ isImage: kind === 'image',
303
+ },
304
+ };
305
+ });
306
+
307
+ const directoryState = openAttachmentDirectory(options.root, options.workItemId);
308
+ const written = [];
309
+ try {
310
+ for (const entry of prepared) {
311
+ writeAttachmentFile(directoryState, entry.attachment.storageName, entry.buffer);
312
+ written.push(entry.attachment);
313
+ }
314
+ return written;
315
+ } catch (error) {
316
+ for (const attachment of written) {
317
+ try { removeAttachmentFile(directoryState, attachment.storageName); } catch {}
318
+ }
319
+ throw error;
320
+ } finally {
321
+ closeDirectoryState(directoryState);
322
+ }
323
+ }
324
+
325
+ export function removeWorkItemAttachmentFiles(root, workItemId, attachments) {
326
+ if (!Array.isArray(attachments) || attachments.length === 0) return;
327
+ const directoryState = openAttachmentDirectory(root, workItemId);
328
+ try {
329
+ for (const attachment of attachments) removeAttachmentFile(directoryState, attachment?.storageName);
330
+ } finally {
331
+ closeDirectoryState(directoryState);
332
+ }
333
+ }
334
+
229
335
  export function removeWorkItemAttachments(root, workItemId, options = {}) {
230
336
  if (!root || !workItemId) return;
231
337
  if (process.platform !== 'linux') {
@@ -286,6 +392,26 @@ function resolveAttachmentPath(root, workItemId, attachment) {
286
392
  return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
287
393
  }
288
394
 
395
+ export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
396
+ const attachment = Array.isArray(workItem?.attachments)
397
+ ? workItem.attachments.find(item => item?.id === attachmentId)
398
+ : null;
399
+ if (!attachment) throw new Error('WorkItem attachment not found');
400
+ const resolved = resolveAttachmentPath(options.root, workItem.id, attachment);
401
+ const buffer = readFileSync(resolved.filePath);
402
+ if (resolved.size !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
403
+ throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
404
+ }
405
+ return {
406
+ id: attachment.id,
407
+ name: attachment.name,
408
+ mimeType: attachment.mimeType,
409
+ size: resolved.size,
410
+ isImage: attachment.isImage === true,
411
+ data: buffer.toString('base64'),
412
+ };
413
+ }
414
+
289
415
  function escapePromptText(value) {
290
416
  return String(value || '')
291
417
  .replace(/&/g, '&')
@@ -20,21 +20,18 @@ let serviceFactory = null;
20
20
  const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
21
21
  // `files` is an internal server-to-Agent field. The browser relay rejects any
22
22
  // client-supplied value and only emits files resolved from owned upload ids.
23
- const BROWSER_CREATE_FIELDS = Object.freeze([
24
- 'title',
25
- 'goal',
26
- 'acceptanceCriteria',
27
- 'workDir',
28
- 'reuseMemory',
29
- 'origin',
30
- 'linkedSessionIds',
31
- 'files',
32
- 'start',
33
- ]);
34
-
35
- function browserCreatePayload(value) {
23
+ const BROWSER_FILE_FIELDS = Object.freeze({
24
+ create: [
25
+ 'title', 'goal', 'acceptanceCriteria', 'workDir', 'reuseMemory', 'origin',
26
+ 'linkedSessionIds', 'files', 'start',
27
+ ],
28
+ guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
29
+ });
30
+
31
+ function browserFilePayload(op, value) {
36
32
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
37
- return Object.fromEntries(BROWSER_CREATE_FIELDS
33
+ const fields = BROWSER_FILE_FIELDS[op] || [];
34
+ return Object.fromEntries(fields
38
35
  .filter(field => Object.prototype.hasOwnProperty.call(source, field))
39
36
  .map(field => [field, source[field]]));
40
37
  }
@@ -176,10 +173,10 @@ export async function handleWorkCenterRequest(msg) {
176
173
  data = await readSettingsResponse();
177
174
  } else {
178
175
  const workCenter = await ensureWorkCenter();
179
- data = await workCenter.handle(
180
- op,
181
- op === 'create' ? browserCreatePayload(msg.payload) : (msg.payload || {}),
182
- );
176
+ const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
177
+ ? browserFilePayload(op, msg.payload)
178
+ : (msg.payload || {});
179
+ data = await workCenter.handle(op, payload);
183
180
  }
184
181
  if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
185
182
  send({
@@ -172,7 +172,9 @@ export class WorkflowController {
172
172
 
173
173
  guide(id, input = {}) {
174
174
  const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
175
- if (!guidance) throw new Error('guidance is required');
175
+ const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
176
+ if (!guidance && addedAttachmentCount === 0) throw new Error('guidance or attachments are required');
177
+ const guidanceSummary = guidance || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
176
178
  const expected = {
177
179
  actionId: typeof input.actionId === 'string' ? input.actionId : '',
178
180
  revision: Number(input.revision),
@@ -180,11 +182,11 @@ export class WorkflowController {
180
182
  if (!expected.actionId || !Number.isInteger(expected.revision)) {
181
183
  throw new Error('actionId and revision are required for guidance');
182
184
  }
183
- const detail = this.store.addActionGuidance(id, guidance, expected, (workItem, previous) => {
185
+ const detail = this.store.addActionGuidance(id, guidanceSummary, expected, (workItem, previous) => {
184
186
  const context = [...(previous.context || []), {
185
187
  type: 'guidance',
186
188
  role: 'user',
187
- summary: guidance,
189
+ summary: guidanceSummary,
188
190
  evidence: [],
189
191
  }];
190
192
  const step = {
@@ -200,7 +202,7 @@ export class WorkflowController {
200
202
  instruction: actionInstruction(step, workItem, context),
201
203
  maxAttempts: previous.maxAttempts || 2,
202
204
  };
203
- });
205
+ }, input.attachments);
204
206
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
205
207
  return detail;
206
208
  }
@@ -5,7 +5,10 @@ import { WorkItemStore } from './store.js';
5
5
  import { WorkflowController } from './controller.js';
6
6
  import { WorkItemWatcher } from './watcher.js';
7
7
  import {
8
+ appendWorkItemAttachments,
8
9
  persistWorkItemAttachments,
10
+ readWorkItemAttachment,
11
+ removeWorkItemAttachmentFiles,
9
12
  removeWorkItemAttachments,
10
13
  } from './attachments.js';
11
14
  import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
@@ -157,15 +160,57 @@ export class WorkCenterService {
157
160
  }
158
161
  case 'guide': {
159
162
  const id = requiredString(payload.id, 'id');
160
- const detail = this.controller.guide(id, {
161
- guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
162
- actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
163
- revision: payload.revision,
164
- });
163
+ const workItem = this.#requiredItem(id);
164
+ let addedAttachments = [];
165
+ let detail;
166
+ try {
167
+ addedAttachments = appendWorkItemAttachments(workItem.attachments, payload.files, {
168
+ root: this.attachmentRoot,
169
+ workItemId: id,
170
+ });
171
+ detail = this.controller.guide(id, {
172
+ guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
173
+ actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
174
+ revision: payload.revision,
175
+ addedAttachmentCount: addedAttachments.length,
176
+ attachments: [...(workItem.attachments || []), ...addedAttachments],
177
+ });
178
+ } catch (error) {
179
+ try {
180
+ if ((workItem.attachments || []).length === 0 && addedAttachments.length > 0) {
181
+ removeWorkItemAttachments(this.attachmentRoot, id);
182
+ } else {
183
+ removeWorkItemAttachmentFiles(this.attachmentRoot, id, addedAttachments);
184
+ }
185
+ } catch {}
186
+ throw error;
187
+ }
165
188
  this.watcher.abortInvalidWorkItemRuns(id);
166
189
  this.#emit({ type: 'action.guidance_added', workItem: detail });
167
190
  return detail;
168
191
  }
192
+ case 'preview_attachment': {
193
+ const id = requiredString(payload.id, 'id');
194
+ const attachment = readWorkItemAttachment(
195
+ this.#requiredItem(id),
196
+ requiredString(payload.attachmentId, 'attachmentId'),
197
+ { root: this.attachmentRoot },
198
+ );
199
+ return {
200
+ attachment: {
201
+ id: attachment.id,
202
+ name: attachment.name,
203
+ mimeType: attachment.mimeType,
204
+ size: attachment.size,
205
+ isImage: attachment.isImage,
206
+ },
207
+ previewData: {
208
+ data: attachment.data,
209
+ mimeType: attachment.mimeType,
210
+ filename: attachment.name,
211
+ },
212
+ };
213
+ }
169
214
  case 'retry': {
170
215
  const detail = this.controller.retry(requiredString(payload.id, 'id'), {
171
216
  answer: typeof payload.answer === 'string' ? payload.answer : '',
@@ -536,7 +536,7 @@ export class WorkItemStore {
536
536
  });
537
537
  }
538
538
 
539
- addActionGuidance(id, guidance, expected, makeAction) {
539
+ addActionGuidance(id, guidance, expected, makeAction, attachments = null) {
540
540
  return withTransaction(this.db, () => {
541
541
  const workItem = this.getWorkItem(id);
542
542
  if (!workItem) return null;
@@ -563,7 +563,12 @@ export class WorkItemStore {
563
563
  contractRevision: workItem.revision,
564
564
  }, this.#nextSequence(id), now);
565
565
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
566
- current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
566
+ current_run_id = NULL, attachments = ?, updated_at = ? WHERE id = ?`).run(
567
+ action.id,
568
+ stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
569
+ now,
570
+ id,
571
+ );
567
572
  this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
568
573
  return this.getWorkItemDetail(id);
569
574
  });