@charzhu/openjaw-agent 0.2.8 → 0.2.9

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/dist/main.js CHANGED
@@ -2393,6 +2393,9 @@ var init_copilot = __esm({
2393
2393
  }
2394
2394
  async chat(options) {
2395
2395
  const modelInfo = await this.resolveModelInfo(this.config.model);
2396
+ if (hasImageContent(options.messages) && modelInfo?.supportsVision === false) {
2397
+ throw new Error(`model ${this.config.model} does not support image input; switch to a vision-capable model before submitting an image`);
2398
+ }
2396
2399
  if (this.shouldRouteToResponses(modelInfo)) {
2397
2400
  return this.chatResponses(options, modelInfo);
2398
2401
  }
@@ -5091,7 +5094,9 @@ ${summary}
5091
5094
  if (imageData) {
5092
5095
  const contentBlocks = [
5093
5096
  { type: "image", source: { type: "base64", media_type: imageData.mimeType, data: imageData.base64 } },
5094
- { type: "text", text: taskText }
5097
+ { type: "text", text: `${taskText}
5098
+
5099
+ [ATTACHED IMAGE]: The image is attached as visual input in this message. Inspect it directly; do not search the filesystem for an uploaded image path unless the user explicitly asks for a file.` }
5095
5100
  ];
5096
5101
  this.conversationHistory.push({ role: "user", content: contentBlocks });
5097
5102
  } else {
@@ -48661,14 +48666,26 @@ ${helpMessage}` : field.label;
48661
48666
  bus.log("info", `session.steer ${String(params.text ?? "").slice(0, 200)}`);
48662
48667
  return { status: "queued", text: String(params.text ?? "") };
48663
48668
  });
48669
+ let pendingImageSeq = 0;
48664
48670
  let pendingImage = null;
48671
+ const nextImageAttachmentId = /* @__PURE__ */ __name(() => `img-${Date.now().toString(36)}-${++pendingImageSeq}`, "nextImageAttachmentId");
48665
48672
  bus.registerRpc("prompt.submit", async (params) => {
48666
48673
  const text = String(params.text ?? "");
48667
48674
  if (!text) return { ok: false };
48668
48675
  const systemPromptArr = await systemPromptFn();
48669
48676
  const systemPrompt = systemPromptArr.join("\n\n");
48670
- const imageData = pendingImage ? { base64: pendingImage.base64, mimeType: pendingImage.mimeType } : void 0;
48671
- pendingImage = null;
48677
+ const requestedImageId = String(params.image_attachment_id ?? "");
48678
+ const imageForSubmit = pendingImage && (!requestedImageId || pendingImage.attachmentId === requestedImageId) ? pendingImage : null;
48679
+ if (imageForSubmit) {
48680
+ const modelInfo = agentLoop.getActiveModelMetadata() ?? await agentLoop.listModels().then(() => agentLoop.getActiveModelMetadata()).catch(() => void 0);
48681
+ if (modelInfo?.supportsVision === false) {
48682
+ throw new Error(`model ${agentLoop.model} does not support image input; switch to a vision-capable model before submitting an image`);
48683
+ }
48684
+ }
48685
+ const imageData = imageForSubmit ? { base64: imageForSubmit.base64, mimeType: imageForSubmit.mimeType } : void 0;
48686
+ if (imageForSubmit) {
48687
+ pendingImage = null;
48688
+ }
48672
48689
  currentRun = { abort: /* @__PURE__ */ __name(() => agentLoop.abort(), "abort") };
48673
48690
  void streamAgentRun({ agentLoop, bus, systemPrompt, text, imageData }).finally(() => {
48674
48691
  currentRun = null;
@@ -48744,6 +48761,32 @@ ${helpMessage}` : field.label;
48744
48761
  });
48745
48762
  });
48746
48763
  bus.registerRpc("clipboard.paste", async () => {
48764
+ try {
48765
+ if (clipboardHasImage()) {
48766
+ const img = readClipboardImage();
48767
+ if (img) {
48768
+ const attachmentId = nextImageAttachmentId();
48769
+ pendingImage = {
48770
+ attachmentId,
48771
+ base64: img.base64,
48772
+ mimeType: img.mimeType,
48773
+ name: "clipboard.png"
48774
+ };
48775
+ const byteLength = Math.ceil(img.base64.length * 3 / 4);
48776
+ const tokenEstimate = Math.max(1, Math.ceil(byteLength / 750));
48777
+ return {
48778
+ attached: true,
48779
+ attachment_id: attachmentId,
48780
+ count: 1,
48781
+ height: img.height,
48782
+ name: "clipboard.png",
48783
+ token_estimate: tokenEstimate,
48784
+ width: img.width
48785
+ };
48786
+ }
48787
+ }
48788
+ } catch {
48789
+ }
48747
48790
  try {
48748
48791
  const text = await readClipboardText();
48749
48792
  return { attached: false, message: text ?? "" };
@@ -49368,13 +49411,16 @@ ${helpMessage}` : field.label;
49368
49411
  }
49369
49412
  const ext = extname4(path3).toLowerCase().replace(/^\./, "");
49370
49413
  const mimeType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/png";
49414
+ const attachmentId = nextImageAttachmentId();
49371
49415
  pendingImage = {
49416
+ attachmentId,
49372
49417
  base64: buffer.toString("base64"),
49373
49418
  mimeType,
49374
49419
  name: basename3(path3)
49375
49420
  };
49376
49421
  const tokenEstimate = Math.max(1, Math.ceil(buffer.byteLength / 750));
49377
49422
  return {
49423
+ attachment_id: attachmentId,
49378
49424
  height: 0,
49379
49425
  name: basename3(path3),
49380
49426
  remainder,
@@ -49382,6 +49428,13 @@ ${helpMessage}` : field.label;
49382
49428
  width: 0
49383
49429
  };
49384
49430
  });
49431
+ bus.registerRpc("image.clear", (params) => {
49432
+ const attachmentId = String(params.attachment_id ?? "");
49433
+ if (!attachmentId || pendingImage?.attachmentId === attachmentId) {
49434
+ pendingImage = null;
49435
+ }
49436
+ return { ok: true };
49437
+ });
49385
49438
  bus.registerRpc("process.stop", () => {
49386
49439
  const wasRunning = agentLoop.isRunning;
49387
49440
  agentLoop.abort();
@@ -49581,6 +49634,7 @@ var init_rpcHandlers = __esm({
49581
49634
  init_uiStore();
49582
49635
  init_catalog();
49583
49636
  init_clipboard();
49637
+ init_clipboard_image();
49584
49638
  init_models_static();
49585
49639
  init_providers();
49586
49640
  init_types();
@@ -52842,6 +52896,7 @@ function useComposerState({
52842
52896
  }) {
52843
52897
  const [input, setInput] = useState12("");
52844
52898
  const [inputBuf, setInputBuf] = useState12([]);
52899
+ const [attachedImage, setAttachedImage] = useState12(null);
52845
52900
  const [pasteSnips, setPasteSnips] = useState12([]);
52846
52901
  const isBlocked = useStore($isBlocked);
52847
52902
  const { querier } = use_stdin_default();
@@ -52859,14 +52914,25 @@ function useComposerState({
52859
52914
  } = useQueue();
52860
52915
  const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory();
52861
52916
  const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw);
52862
- const clearIn = useCallback7(() => {
52917
+ const clearAttachedImage = useCallback7(() => {
52918
+ const attachmentId = attachedImage?.attachment_id;
52919
+ setAttachedImage(null);
52920
+ if (attachmentId) {
52921
+ void gw.request("image.clear", { attachment_id: attachmentId }).catch(() => {
52922
+ });
52923
+ }
52924
+ }, [attachedImage?.attachment_id, gw]);
52925
+ const clearIn = useCallback7((options = {}) => {
52863
52926
  setInput("");
52864
52927
  setInputBuf([]);
52865
52928
  setPasteSnips([]);
52929
+ if (!options.keepAttachedImage) {
52930
+ clearAttachedImage();
52931
+ }
52866
52932
  setQueueEdit(null);
52867
52933
  setHistoryIdx(null);
52868
52934
  historyDraftRef.current = "";
52869
- }, [historyDraftRef, setQueueEdit, setHistoryIdx]);
52935
+ }, [clearAttachedImage, historyDraftRef, setQueueEdit, setHistoryIdx]);
52870
52936
  const handleResolvedPaste = useCallback7(
52871
52937
  async ({
52872
52938
  bracketed,
@@ -52889,6 +52955,13 @@ function useComposerState({
52889
52955
  session_id: sid
52890
52956
  });
52891
52957
  if (attached?.name) {
52958
+ setAttachedImage({
52959
+ attachment_id: attached.attachment_id,
52960
+ height: attached.height,
52961
+ name: attached.name,
52962
+ token_estimate: attached.token_estimate,
52963
+ width: attached.width
52964
+ });
52892
52965
  onImageAttached?.(attached);
52893
52966
  const remainder = attached.remainder?.trim() ?? "";
52894
52967
  if (!remainder) {
@@ -52941,7 +53014,7 @@ function useComposerState({
52941
53014
  }) => {
52942
53015
  if (hotkey) {
52943
53016
  const preferOsc52 = isRemoteShellSession(process.env);
52944
- const readPreferredText = preferOsc52 ? readOsc52Clipboard(querier).then(async (osc52Text) => {
53017
+ const readPreferredText = /* @__PURE__ */ __name(() => preferOsc52 ? readOsc52Clipboard(querier).then(async (osc52Text) => {
52945
53018
  if (isUsableClipboardText(osc52Text)) {
52946
53019
  return osc52Text;
52947
53020
  }
@@ -52951,8 +53024,12 @@ function useComposerState({
52951
53024
  return clipText;
52952
53025
  }
52953
53026
  return readOsc52Clipboard(querier);
52954
- });
52955
- return readPreferredText.then(async (preferredText) => {
53027
+ }), "readPreferredText");
53028
+ return Promise.resolve(onClipboardPaste(true)).then(async (imageAttached) => {
53029
+ if (imageAttached) {
53030
+ return null;
53031
+ }
53032
+ const preferredText = await readPreferredText();
52956
53033
  if (isUsableClipboardText(preferredText)) {
52957
53034
  return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value });
52958
53035
  }
@@ -52990,6 +53067,7 @@ function useComposerState({
52990
53067
  }, [input, inputBuf, submitRef]);
52991
53068
  const actions = useMemo6(
52992
53069
  () => ({
53070
+ clearAttachedImage,
52993
53071
  clearIn,
52994
53072
  dequeue,
52995
53073
  enqueue,
@@ -53000,6 +53078,7 @@ function useComposerState({
53000
53078
  replaceQueue: replaceQ,
53001
53079
  setCompIdx,
53002
53080
  setHistoryIdx,
53081
+ setAttachedImage,
53003
53082
  setInput,
53004
53083
  setInputBuf,
53005
53084
  setPasteSnips,
@@ -53007,6 +53086,7 @@ function useComposerState({
53007
53086
  syncQueue
53008
53087
  }),
53009
53088
  [
53089
+ clearAttachedImage,
53010
53090
  clearIn,
53011
53091
  dequeue,
53012
53092
  enqueue,
@@ -53033,6 +53113,7 @@ function useComposerState({
53033
53113
  );
53034
53114
  const state = useMemo6(
53035
53115
  () => ({
53116
+ attachedImage,
53036
53117
  compIdx,
53037
53118
  compReplace,
53038
53119
  completions,
@@ -53043,7 +53124,7 @@ function useComposerState({
53043
53124
  queueEditIdx,
53044
53125
  queuedDisplay
53045
53126
  }),
53046
- [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
53127
+ [attachedImage, compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
53047
53128
  );
53048
53129
  return {
53049
53130
  actions,
@@ -53632,6 +53713,9 @@ function useInputHandlers(ctx) {
53632
53713
  if (key.escape && terminal.hasSelection) {
53633
53714
  return clearSelection2();
53634
53715
  }
53716
+ if (key.escape && cState.attachedImage) {
53717
+ return cActions.clearAttachedImage();
53718
+ }
53635
53719
  if (key.escape && live.focusedPane === "transcript") {
53636
53720
  patchUiState({ focusedPane: "composer" });
53637
53721
  return;
@@ -53680,7 +53764,7 @@ function useInputHandlers(ctx) {
53680
53764
  sys: actions.sys
53681
53765
  });
53682
53766
  }
53683
- if (cState.input || cState.inputBuf.length) {
53767
+ if (cState.input || cState.inputBuf.length || cState.attachedImage) {
53684
53768
  return cActions.clearIn();
53685
53769
  }
53686
53770
  return actions.die();
@@ -53870,6 +53954,7 @@ function useSessionLifecycle(opts) {
53870
53954
  setHistoryItems([]);
53871
53955
  setLastUserMsg("");
53872
53956
  setStickyPrompt("");
53957
+ composerActions.clearAttachedImage();
53873
53958
  composerActions.setPasteSnips([]);
53874
53959
  evictInkCaches("half");
53875
53960
  }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]);
@@ -53882,6 +53967,7 @@ function useSessionLifecycle(opts) {
53882
53967
  setHistoryItems(info ? [introMsg(info)] : []);
53883
53968
  setStickyPrompt("");
53884
53969
  setLastUserMsg("");
53970
+ composerActions.clearAttachedImage();
53885
53971
  composerActions.setPasteSnips([]);
53886
53972
  patchTurnState({ activity: [] });
53887
53973
  patchUiState({ info, usage: usageFrom(info) });
@@ -54112,6 +54198,7 @@ function useSubmission(opts) {
54112
54198
  const expand = expandSnips(composerState.pasteSnips);
54113
54199
  const startSubmit = /* @__PURE__ */ __name((displayText, submitText, showUserMessage2 = true) => {
54114
54200
  const sid2 = getUiState().sid;
54201
+ const imageAttachment = composerState.attachedImage;
54115
54202
  if (!sid2) {
54116
54203
  return sys("session not ready yet");
54117
54204
  }
@@ -54124,7 +54211,20 @@ function useSubmission(opts) {
54124
54211
  patchUiState({ busy: true, status: "running\u2026" });
54125
54212
  turnController.bufRef = "";
54126
54213
  turnController.interrupted = false;
54127
- gw.request("prompt.submit", { session_id: sid2, text: submitText }).catch((e) => {
54214
+ const submitParams = { session_id: sid2, text: submitText };
54215
+ if (imageAttachment?.attachment_id) {
54216
+ submitParams.image_attachment_id = imageAttachment.attachment_id;
54217
+ }
54218
+ if (imageAttachment) {
54219
+ composerActions.setAttachedImage(null);
54220
+ }
54221
+ gw.request("prompt.submit", submitParams).catch((e) => {
54222
+ if (imageAttachment) {
54223
+ composerActions.setAttachedImage(imageAttachment);
54224
+ sys(`error: ${e.message}`);
54225
+ patchUiState({ busy: false, status: "ready" });
54226
+ return;
54227
+ }
54128
54228
  if (isSessionBusyError(e)) {
54129
54229
  composerActions.enqueue(submitText);
54130
54230
  patchUiState({ busy: true, status: "queued for next turn" });
@@ -54150,7 +54250,7 @@ function useSubmission(opts) {
54150
54250
  startSubmit(r.text || text, expand(r.text || text), showUserMessage);
54151
54251
  }).catch(() => startSubmit(text, expand(text), showUserMessage));
54152
54252
  },
54153
- [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
54253
+ [appendMessage, composerActions, composerState.attachedImage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
54154
54254
  );
54155
54255
  const shellExec = useCallback9(
54156
54256
  (cmd) => {
@@ -54213,6 +54313,16 @@ function useSubmission(opts) {
54213
54313
  }
54214
54314
  sys(note);
54215
54315
  }, "fallback");
54316
+ if (composerState.attachedImage) {
54317
+ if (live.sid) {
54318
+ turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys });
54319
+ }
54320
+ if (hasInterpolation(full)) {
54321
+ patchUiState({ busy: true });
54322
+ return interpolate(full, send);
54323
+ }
54324
+ return send(full);
54325
+ }
54216
54326
  if (mode === "queue") {
54217
54327
  return composerActions.enqueue(full);
54218
54328
  }
@@ -54234,7 +54344,7 @@ function useSubmission(opts) {
54234
54344
  }
54235
54345
  send(full);
54236
54346
  },
54237
- [appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
54347
+ [appendMessage, composerActions, composerRefs, composerState.attachedImage, gw, interpolate, send, sys]
54238
54348
  );
54239
54349
  const dispatchSubmission = useCallback9(
54240
54350
  (full) => {
@@ -54259,8 +54369,8 @@ function useSubmission(opts) {
54259
54369
  return;
54260
54370
  }
54261
54371
  const editIdx = composerRefs.queueEditRef.current;
54262
- composerActions.clearIn();
54263
54372
  if (editIdx !== null) {
54373
+ composerActions.clearIn();
54264
54374
  composerActions.replaceQueue(editIdx, full);
54265
54375
  const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0];
54266
54376
  composerActions.syncQueue();
@@ -54277,6 +54387,7 @@ function useSubmission(opts) {
54277
54387
  }
54278
54388
  return sendQueued(picked);
54279
54389
  }
54390
+ composerActions.clearIn({ keepAttachedImage: !!composerState.attachedImage });
54280
54391
  composerActions.pushHistory(full);
54281
54392
  if (getUiState().busy) {
54282
54393
  return handleBusyInput(full);
@@ -54287,7 +54398,7 @@ function useSubmission(opts) {
54287
54398
  }
54288
54399
  send(full);
54289
54400
  },
54290
- [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
54401
+ [appendMessage, composerActions, composerRefs, composerState.attachedImage, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
54291
54402
  );
54292
54403
  const submit = useCallback9(
54293
54404
  (value) => {
@@ -54405,8 +54516,7 @@ function useMainApp(gw) {
54405
54516
  const scrollRef = useRef13(null);
54406
54517
  const onEventRef = useRef13(() => {
54407
54518
  });
54408
- const clipboardPasteRef = useRef13(() => {
54409
- });
54519
+ const clipboardPasteRef = useRef13(() => false);
54410
54520
  const submitRef = useRef13(() => {
54411
54521
  });
54412
54522
  const terminalHintsShownRef = useRef13(/* @__PURE__ */ new Set());
@@ -54459,9 +54569,7 @@ function useMainApp(gw) {
54459
54569
  const composer = useComposerState({
54460
54570
  gw,
54461
54571
  onClipboardPaste: /* @__PURE__ */ __name((quiet) => clipboardPasteRef.current(quiet), "onClipboardPaste"),
54462
- onImageAttached: /* @__PURE__ */ __name((info) => {
54463
- sys(attachedImageNotice(info));
54464
- }, "onImageAttached"),
54572
+ onImageAttached: /* @__PURE__ */ __name(() => patchUiState({ status: "image attached" }), "onImageAttached"),
54465
54573
  submitRef
54466
54574
  });
54467
54575
  const { actions: composerActions, refs: composerRefs, state: composerState } = composer;
@@ -54669,17 +54777,25 @@ function useMainApp(gw) {
54669
54777
  const paste2 = useCallback10(
54670
54778
  (quiet = false) => rpc("clipboard.paste", { session_id: getUiState().sid }).then((r) => {
54671
54779
  if (!r) {
54672
- return;
54780
+ return false;
54673
54781
  }
54674
54782
  if (r.attached) {
54675
- const meta = imageTokenMeta(r);
54676
- return sys(`\u{1F4CE} Image #${r.count} attached from clipboard${meta ? ` \xB7 ${meta}` : ""}`);
54783
+ composerActions.setAttachedImage({
54784
+ attachment_id: r.attachment_id,
54785
+ height: r.height,
54786
+ name: r.name ?? `Image #${r.count ?? 1}`,
54787
+ token_estimate: r.token_estimate,
54788
+ width: r.width
54789
+ });
54790
+ patchUiState({ status: "image attached" });
54791
+ return true;
54677
54792
  }
54678
54793
  if (!quiet) {
54679
54794
  sys(r.message || "No image found in clipboard");
54680
54795
  }
54796
+ return false;
54681
54797
  }),
54682
- [rpc, sys]
54798
+ [composerActions, rpc, sys]
54683
54799
  );
54684
54800
  clipboardPasteRef.current = paste2;
54685
54801
  const { dispatchSubmission, send, sendQueued, submit } = useSubmission({
@@ -54919,6 +55035,7 @@ function useMainApp(gw) {
54919
55035
  cols,
54920
55036
  compIdx: composerState.compIdx,
54921
55037
  completions: composerState.completions,
55038
+ attachedImage: composerState.attachedImage,
54922
55039
  empty,
54923
55040
  handleTextPaste: composerActions.handleTextPaste,
54924
55041
  input: composerState.input,
@@ -54975,7 +55092,6 @@ var init_useMainApp = __esm({
54975
55092
  init_env();
54976
55093
  init_limits();
54977
55094
  init_details();
54978
- init_messages();
54979
55095
  init_paths();
54980
55096
  init_useGitBranch();
54981
55097
  init_useVirtualHistory();
@@ -62280,6 +62396,7 @@ var init_appLayout = __esm({
62280
62396
  init_env();
62281
62397
  init_limits();
62282
62398
  init_placeholders();
62399
+ init_messages();
62283
62400
  init_inputMetrics();
62284
62401
  init_perfPane();
62285
62402
  init_agentsOverlay();
@@ -62399,6 +62516,7 @@ var init_appLayout = __esm({
62399
62516
  const inputColumns = stableComposerColumns(composer.cols, promptWidth);
62400
62517
  const inputHeight = inputVisualHeight(composer.input, inputColumns);
62401
62518
  const inputMouseRef = useRef19(null);
62519
+ const attachedImageMeta = composer.attachedImage ? imageTokenMeta(composer.attachedImage) : "";
62402
62520
  const captureInputDrag = /* @__PURE__ */ __name((e) => {
62403
62521
  if (e.button !== 0) {
62404
62522
  return;
@@ -62469,6 +62587,12 @@ var init_appLayout = __esm({
62469
62587
  ),
62470
62588
  composer.input === "?" && !composer.inputBuf.length && /* @__PURE__ */ jsx41(HelpHint, { t: ui.theme }),
62471
62589
  !isBlocked && /* @__PURE__ */ jsxs28(Fragment11, { children: [
62590
+ composer.attachedImage && /* @__PURE__ */ jsx41(Box_default, { width: Math.max(1, composer.cols - 2), children: /* @__PURE__ */ jsxs28(Text9, { color: ui.theme.color.systemNote, wrap: "truncate-end", children: [
62591
+ "\u{1F4CE} ",
62592
+ composer.attachedImage.name,
62593
+ attachedImageMeta ? ` \xB7 ${attachedImageMeta}` : "",
62594
+ /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.muted, children: " \xB7 Esc to remove" })
62595
+ ] }) }),
62472
62596
  composer.inputBuf.map((line, i) => /* @__PURE__ */ jsxs28(Box_default, { children: [
62473
62597
  /* @__PURE__ */ jsx41(Box_default, { width: promptWidth, children: i === 0 ? /* @__PURE__ */ jsx41(PromptPrefix, { color: ui.theme.color.muted, promptText, width: promptWidth }) : /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.muted, children: promptBlank }) }),
62474
62598
  /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.composeText, children: line || " " })