@bivy/bivy 0.18.4-staging.2 → 0.18.4-staging.4

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.
@@ -528,7 +528,12 @@ async function onBivyCommand(msg) {
528
528
  bivy({ replyTo: id, ok: true });
529
529
  asRequest("turn/start", {
530
530
  threadId,
531
- input: [{ type: "text", text: String(msg.text ?? "") }],
531
+ input: [
532
+ { type: "text", text: String(msg.text ?? "") },
533
+ ...(msg.images ?? []).map((image) => ({
534
+ type: "image", url: `data:${image.mimeType || "image/png"};base64,${image.data}`,
535
+ })),
536
+ ],
532
537
  ...(selectedModel ? { model: selectedModel } : {}),
533
538
  })
534
539
  .catch((error) => bivy({ type: "session.error", error: error instanceof Error ? error.message : String(error) }));
@@ -50,8 +50,15 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
50
50
  #mic[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
51
51
  #voice-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
52
  #voice-status:empty { display:none; }
53
- /* The panel scrolls on a phone; its actions stay in reach at the bottom. */
54
- #draft .panel-actions { position:sticky; bottom:calc(-1 * var(--space-3)); margin:var(--space-2) calc(-1 * var(--space-3)) calc(-1 * var(--space-3)); padding:var(--space-2) var(--space-3) var(--space-3); background:var(--surface); border-top:thin solid var(--line); }
53
+ /* The note owns the dock while editing. Only its body scrolls; actions never
54
+ cover the text field, even when the keyboard leaves little vertical room. */
55
+ #dock { max-height:calc(100% - var(--space-4)); }
56
+ #dock:has(#draft:not([hidden])) nav, #dock:has(#draft:not([hidden])) #pointing { display:none; }
57
+ #draft { display:flex; flex-direction:column; max-height:70dvh; overflow:hidden; padding:0; }
58
+ #draft-body { min-height:0; overflow:auto; padding:var(--space-3); }
59
+ #draft .panel-actions { flex-shrink:0; margin:0; padding:var(--space-2) var(--space-3); border-top:thin solid var(--line); }
60
+ #draft details { margin-top:var(--space-2); }
61
+ #draft summary { color:var(--muted); font-size:var(--text-sm); cursor:pointer; }
55
62
  #draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
56
63
  #draft-context { margin:0; padding:var(--space-2); background:var(--surface-2); border-radius:var(--radius-md); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); }
57
64
  #show { position:fixed; right:var(--space-3); bottom:calc(var(--space-3) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
@@ -94,12 +101,14 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
94
101
  <button class="btn sm" id="compare-draw" type="button" hidden>Draw on “now”</button>
95
102
  </section>
96
103
  <section class="panel" id="draft" hidden aria-labelledby="draft-title">
97
- <h2 id="draft-title">Draft for the agent</h2>
104
+ <div id="draft-body">
105
+ <h2 id="draft-title">Note for the agent</h2>
98
106
  <div id="draft-row"><textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
99
107
  <button class="btn" id="mic" type="button" hidden aria-pressed="false" aria-label="Speak" title="Hold to speak, or tap to start and stop"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5a3 3 0 003-3V6a3 3 0 10-6 0v6.5a3 3 0 003 3z"/><path d="M6 11.5v1a6 6 0 0012 0v-1M12 18.5V21M9 21h6"/></svg></button></div>
100
108
  <p class="muted" id="voice-status" role="status"></p>
101
- <p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
102
- <pre id="draft-context"></pre>
109
+ <p class="muted" id="draft-hint">Review in chat before sending.</p>
110
+ <details id="draft-details"><summary>Preview context</summary><pre id="draft-context"></pre></details>
111
+ </div>
103
112
  <div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
104
113
  </section>
105
114
  <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. <span id="pointing-voice" hidden>Hold to point and speak. </span>Press Escape or Point again to stop.</p>
@@ -191,6 +200,7 @@ function draft(context,listen){
191
200
  $('draft').hidden=false;
192
201
  $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
193
202
  $('draft-context').textContent=context;
203
+ $('draft-details').open=false;
194
204
  const box=$('draft-text');box.value='';
195
205
  // Speaking right away: don't raise the keyboard over the draft.
196
206
  if(listen)startListening();else box.focus();
@@ -227,13 +237,13 @@ function fromBivy(d){
227
237
  else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
228
238
  else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
229
239
  }
230
- $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;endDraw();};
240
+ $('draft-cancel').onclick=()=>{const target=marks?$('draw'):point;cancelListening();$('draft').hidden=true;endDraw();target.focus();};
231
241
  $('draft-add').onclick=async()=>{
232
242
  cancelListening();
233
243
  const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
234
244
  // Marks go to the Bivy client, which adds a picture made on the machine.
235
- if(marks){const mark=marks;endDraw();return toBivy({type:'annotation',text,mark});}
236
- if(metadata.returnTo)return toChat(text);
245
+ if(marks){const mark=marks;$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
246
+ if(metadata.returnTo){$('draft').hidden=true;return toChat(text);}
237
247
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
238
248
  };
239
249
  // Console
@@ -443,7 +453,7 @@ addEventListener('message',e=>{
443
453
  }
444
454
  });
445
455
  ask.onclick=()=>toChat('The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.');
446
- addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
456
+ addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(!$('draft').hidden)$('draft-cancel').click();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
447
457
  back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
448
458
  reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
449
459
  (async()=>{
@@ -3371,3 +3371,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3371
3371
  the composer itself, with one line of another device's draft. */
3372
3372
  .handoff-banner { border-radius: var(--radius-md); }
3373
3373
  .handoff-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3374
+
3375
+ /* Keep editorial type on the wordmark; session titles retain the app's sans. */
3376
+ .brand {
3377
+ font-family: var(--font-serif);
3378
+ font-weight: var(--weight-medium);
3379
+ }
@@ -30,37 +30,37 @@
30
30
  /* ---- Core surfaces & text (neutral) ---- */
31
31
  --bg: #ffffff;
32
32
  --surface: #ffffff;
33
- --surface-2: #f3f5f7;
34
- --surface-3: #e8ecef;
35
- --ink: #0f1215;
36
- --muted: #5f6a73;
37
- --line: #e3e7ea;
38
- --line-strong: #cdd4d9;
33
+ --surface-2: #f5f5f5;
34
+ --surface-3: #ebebeb;
35
+ --ink: #222222;
36
+ --muted: #666666;
37
+ --line: #e5e5e5;
38
+ --line-strong: #c5c5c5;
39
39
  --overlay: rgba(0, 0, 0, 0.46);
40
40
  --overlay-strong: rgba(0, 0, 0, 0.8);
41
41
 
42
42
  /* ---- Accent (reserved for primary actions and active status) ---- */
43
- --accent: #3f6377;
43
+ --accent: #ac4b24;
44
44
  --accent-contrast: #ffffff;
45
- --accent-hover: #324f60;
45
+ --accent-hover: #873b1c;
46
46
  --accent-active: color-mix(in srgb, var(--accent), #000 22%);
47
- --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--surface));
47
+ --accent-soft: #f5e5d9;
48
48
 
49
49
  /* ---- Status ---- */
50
- --danger: #b0473d; --danger-soft: #f3e3df; --danger-contrast: #ffffff;
51
- --ok: #4f7a5c;
52
- --merged: #7a6a9e;
50
+ --danger: #a63d35; --danger-soft: #f4e3dd; --danger-contrast: #ffffff;
51
+ --ok: #506d43;
52
+ --merged: #796188;
53
53
  /* A run that finished while you weren't looking — its own signal, deliberately
54
54
  not accent (working) / danger (needs input) / ok / merged, so
55
55
  "something's ready" reads at a glance. */
56
- --unseen: #a8823f;
56
+ --unseen: #967021;
57
57
  --unseen-contrast: #111111;
58
58
 
59
59
  /* ---- Per-source hues for session/run marks (see packages/web sessionSource).
60
60
  Kept clear of the status palette above so a source is never mistaken for an
61
61
  outcome; app/cli stay quiet so hand-opened sessions don't shout. ---- */
62
62
  --s-github: #24292f; --s-slack: #7c2d92; --s-linear: #5e6ad2; --s-schedule: #0e7490;
63
- --s-webhook: #6d28d9; --s-manual: #b45309; --s-cli: #3f6212; --s-app: #64748b;
63
+ --s-webhook: #6d28d9; --s-manual: #b45309; --s-cli: #526d3b; --s-app: #6a785a;
64
64
 
65
65
  /* ---- Annotation: marks drawn over an app preview. The same in both themes
66
66
  (the app underneath has its own), vivid and rare in real UIs, with a white
@@ -69,8 +69,8 @@
69
69
  --annotate-halo: #ffffff;
70
70
 
71
71
  /* ---- Terminal text selection ---- */
72
- --term-selection: rgba(63, 99, 119, 0.30);
73
- --term-selection-inactive: rgba(63, 99, 119, 0.20);
72
+ --term-selection: rgba(119, 141, 91, 0.30);
73
+ --term-selection-inactive: rgba(119, 141, 91, 0.18);
74
74
 
75
75
  /* ---- Syntax highlighting ---- */
76
76
  --syntax-comment: #6a737d; --syntax-keyword: #d73a49; --syntax-string: #032f62;
@@ -106,18 +106,18 @@
106
106
  --space-5: 24px; --space-6: 32px; --space-7: 48px; --space-8: 64px;
107
107
 
108
108
  /* ---- Radius ---- */
109
- --radius: 12px; /* app default — same value as --radius-md */
110
- --radius-sm: 8px;
111
- --radius-md: 12px;
112
- --radius-lg: 16px;
113
- --radius-xl: 20px;
109
+ --radius: 8px; /* app default — same value as --radius-md */
110
+ --radius-sm: 6px;
111
+ --radius-md: 8px;
112
+ --radius-lg: 12px;
113
+ --radius-xl: 16px;
114
114
  --radius-full: 999px;
115
115
 
116
116
  /* ---- Elevation ---- */
117
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.08);
118
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
119
- --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
120
- --shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.16);
117
+ --shadow-sm: 0 1px 3px rgba(40, 45, 36, 0.06);
118
+ --shadow-md: 0 4px 16px rgba(40, 45, 36, 0.08);
119
+ --shadow-lg: 0 12px 32px rgba(40, 45, 36, 0.12);
120
+ --shadow-xl: 0 24px 70px rgba(40, 45, 36, 0.16);
121
121
 
122
122
  /* ---- Motion ---- */
123
123
  --dur-fast: 120ms;
@@ -150,59 +150,66 @@
150
150
  definition (they only "worked" via an inline var(--x, #fallback)). Defined
151
151
  here so the fallback is never load-bearing and the value is consistent
152
152
  everywhere the name appears. Prefer the canonical name on the right. ==== */
153
- --warn: #b8862f; --warn-soft: color-mix(in srgb, #b8862f 16%, var(--surface));
153
+ --warn: #967021; --warn-soft: #f0e8cf;
154
154
  --err: var(--danger);
155
155
  --add: var(--ok); /* diff addition */
156
156
  --del: var(--danger); /* diff deletion */
157
157
  --fg: var(--ink);
158
158
  --surface-1: var(--surface);
159
159
  --mono: var(--font-mono);
160
+
160
161
  }
161
162
 
162
163
  :root[data-theme="light"] { color-scheme: light; }
163
164
 
164
165
  :root[data-theme="dark"] {
165
166
  color-scheme: dark;
166
- --bg: #000000; --surface: #000000; --surface-2: #15181b; --surface-3: #22272b;
167
- --ink: #f3f5f7; --muted: #99a2aa; --line: #1f2327; --line-strong: #353b41;
167
+ --bg: #000000; --surface: #080808; --surface-2: #141414; --surface-3: #202020;
168
+ --ink: #f3f3f3; --muted: #aaaaaa; --line: #262626; --line-strong: #484848;
168
169
  --overlay: rgba(0, 0, 0, 0.7); --overlay-strong: rgba(0, 0, 0, 0.8);
169
- --accent: #8fb6cd; --accent-contrast: #0a0a0a; --accent-hover: #a7cadd;
170
- --danger: #c08a83; --danger-soft: #3a2420; --danger-contrast: #0a0a0a; --ok: #86a99f; --merged: #a99bc4;
171
- --unseen: #cba96a; --unseen-contrast: #0a0a0a;
172
- --warn: #d9a441;
170
+ --accent: #edab7e; --accent-contrast: #271c12; --accent-hover: #f6c29f;
171
+ --danger: #f0a497; --danger-soft: #4a3029; --danger-contrast: #0a0a0a; --ok: #b3ce96; --merged: #cab4d4;
172
+ --unseen: #ddc181; --unseen-contrast: #0a0a0a;
173
+ --warn: #ddc181;
173
174
  --s-github: #d7dde3; --s-slack: #d68ce6; --s-linear: #a5adf5; --s-schedule: #4bc4de;
174
- --s-webhook: #b79bf7; --s-manual: #f0a45a; --s-cli: #a3c987; --s-app: #9aa4b2;
175
- --term-selection: rgba(143, 182, 205, 0.36); --term-selection-inactive: rgba(143, 182, 205, 0.24);
175
+ --s-webhook: #b79bf7; --s-manual: #f0a45a; --s-cli: #adc58f; --s-app: #b2c69a;
176
+ --term-selection: rgba(178, 198, 154, 0.30); --term-selection-inactive: rgba(178, 198, 154, 0.18);
176
177
  --syntax-comment: #8b949e; --syntax-keyword: #ff7b72; --syntax-string: #a5d6ff;
177
178
  --syntax-number: #79c0ff; --syntax-title: #d2a8ff; --syntax-type: #ffa657;
178
179
  --syntax-name: #7ee787; --syntax-deletion: #ffdcd7; --syntax-addition: #85e89d;
179
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
180
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
181
- --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
182
- --shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.6);
180
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.20);
181
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.24);
182
+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.28);
183
+ --shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.32);
184
+
185
+ --accent-soft: #493c2c;
186
+ --warn-soft: #49432b;
183
187
  }
184
188
 
185
189
  @media (prefers-color-scheme: dark) {
186
190
  :root:not([data-theme]) {
187
191
  color-scheme: dark;
188
- --bg: #000000; --surface: #000000; --surface-2: #15181b; --surface-3: #22272b;
189
- --ink: #f3f5f7; --muted: #99a2aa; --line: #1f2327; --line-strong: #353b41;
192
+ --bg: #000000; --surface: #080808; --surface-2: #141414; --surface-3: #202020;
193
+ --ink: #f3f3f3; --muted: #aaaaaa; --line: #262626; --line-strong: #484848;
190
194
  --overlay: rgba(0, 0, 0, 0.7); --overlay-strong: rgba(0, 0, 0, 0.8);
191
- --accent: #8fb6cd; --accent-contrast: #0a0a0a; --accent-hover: #a7cadd;
192
- --danger: #c08a83; --danger-soft: #3a2420; --danger-contrast: #0a0a0a; --ok: #86a99f; --merged: #a99bc4;
193
- --unseen: #cba96a; --unseen-contrast: #0a0a0a;
194
- --warn: #d9a441;
195
+ --accent: #edab7e; --accent-contrast: #271c12; --accent-hover: #f6c29f;
196
+ --danger: #f0a497; --danger-soft: #4a3029; --danger-contrast: #0a0a0a; --ok: #b3ce96; --merged: #cab4d4;
197
+ --unseen: #ddc181; --unseen-contrast: #0a0a0a;
198
+ --warn: #ddc181;
195
199
  --s-github: #d7dde3; --s-slack: #d68ce6; --s-linear: #a5adf5; --s-schedule: #4bc4de;
196
- --s-webhook: #b79bf7; --s-manual: #f0a45a; --s-cli: #a3c987; --s-app: #9aa4b2;
197
- --term-selection: rgba(143, 182, 205, 0.36); --term-selection-inactive: rgba(143, 182, 205, 0.24);
200
+ --s-webhook: #b79bf7; --s-manual: #f0a45a; --s-cli: #adc58f; --s-app: #b2c69a;
201
+ --term-selection: rgba(178, 198, 154, 0.30); --term-selection-inactive: rgba(178, 198, 154, 0.18);
198
202
  --syntax-comment: #8b949e; --syntax-keyword: #ff7b72; --syntax-string: #a5d6ff;
199
203
  --syntax-number: #79c0ff; --syntax-title: #d2a8ff; --syntax-type: #ffa657;
200
204
  --syntax-name: #7ee787; --syntax-deletion: #ffdcd7; --syntax-addition: #85e89d;
201
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
202
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
203
- --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
204
- --shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.6);
205
- }
205
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.20);
206
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.24);
207
+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.28);
208
+ --shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.32);
209
+
210
+ --accent-soft: #493c2c;
211
+ --warn-soft: #49432b;
212
+ }
206
213
  }
207
214
 
208
215
  @media (prefers-reduced-motion: reduce) {
package/dist/server.js CHANGED
@@ -132,6 +132,7 @@ import { EventLog } from "./session/event-log.js";
132
132
  import { SessionEventSequencer } from "./session/event-sequencer.js";
133
133
  import { revertFile } from "./session/revert-file.js";
134
134
  import { buildDiagnosticsReport, activationRecord } from "./diagnostics.js";
135
+ import { attachmentsFrom, materializeAttachments, sanitizeAttachmentFilename } from "./session/prompt-attachments.js";
135
136
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
136
137
  import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
137
138
  import { ReplicationService } from "./session/replication-service.js";
@@ -1007,6 +1008,7 @@ async function setDefaultRuntime(id) {
1007
1008
  broadcast({ type: "runtime.updated", current: runtimeSummary(rt), runtimes: runtimeList(rt.id) });
1008
1009
  return rt;
1009
1010
  }
1011
+ // StreamingBehavior + PromptImage moved to ./session/record.ts (step 2a).
1010
1012
  function streamingBehaviorFrom(value) {
1011
1013
  return value === "steer" || value === "followUp" ? value : undefined;
1012
1014
  }
@@ -1029,151 +1031,6 @@ function promptForAgent(_record, promptText) {
1029
1031
  // remote PWA when history arrives.
1030
1032
  return promptText;
1031
1033
  }
1032
- function safeAttachmentName(value) {
1033
- return String(value || "attachment").replace(/[\r\n]/g, " ").slice(0, 180);
1034
- }
1035
- /**
1036
- * Split composer attachments into channels:
1037
- * - `images` — base64 blobs passed to the model as vision.
1038
- * - `imageNotes` — one prose line per image for the persisted transcript.
1039
- * - `imageRefs` — durable AttachmentStore references for the images, persisted
1040
- * in the event log so they rehydrate after a reload / on
1041
- * another device (images used to be vision-only, then lost).
1042
- * - `files` — decoded file attachments (bytes or text) to be written to
1043
- * disk by materializeAttachments so the agent can open them
1044
- * with its normal file tools. Any file type is supported;
1045
- * binary files arrive as base64 `data`.
1046
- */
1047
- const MAX_PROMPT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
1048
- const MAX_PROMPT_ATTACHMENTS_BYTES = 40 * 1024 * 1024;
1049
- function attachmentsFrom(value) {
1050
- if (!Array.isArray(value))
1051
- return { images: [], imageNotes: [], imageRefs: [], files: [] };
1052
- const images = [];
1053
- const imageNotes = [];
1054
- const imageRefs = [];
1055
- const files = [];
1056
- let totalBytes = 0;
1057
- if (value.length > 12)
1058
- throw new Error("A message can include at most 12 attachments");
1059
- for (const raw of value) {
1060
- if (!raw || typeof raw !== "object")
1061
- continue;
1062
- const attachment = raw;
1063
- const name = safeAttachmentName(attachment.name);
1064
- const size = Number(attachment.size || 0);
1065
- const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
1066
- const encodedBytes = typeof attachment.data === "string" ? Math.floor(attachment.data.length * 3 / 4) : 0;
1067
- const textBytes = attachment.kind === "file" && typeof attachment.text === "string" ? Buffer.byteLength(attachment.text) : 0;
1068
- const actualBytes = encodedBytes || textBytes;
1069
- if (actualBytes > MAX_PROMPT_ATTACHMENT_BYTES)
1070
- throw new Error(`${name} exceeds the 10 MiB attachment limit`);
1071
- totalBytes += actualBytes;
1072
- if (totalBytes > MAX_PROMPT_ATTACHMENTS_BYTES)
1073
- throw new Error("Attachments exceed the 40 MiB per-message limit");
1074
- if (attachment.kind === "image" && typeof attachment.data === "string") {
1075
- const imgMime = mimeType ?? "image/png";
1076
- images.push({ type: "image", data: attachment.data, mimeType: imgMime });
1077
- imageNotes.push(`[Image attachment: ${name}${size ? ` (${size} bytes)` : ""}]`);
1078
- // Persist the image bytes durably (dedup by hash). Best-effort: a store
1079
- // failure must not break vision for the turn, so it only costs the ref.
1080
- try {
1081
- imageRefs.push(attachmentStore.put(Buffer.from(attachment.data, "base64"), { name, mimeType: imgMime, kind: "image" }));
1082
- }
1083
- catch (error) {
1084
- console.warn("[attachments] failed to store image:", error instanceof Error ? error.message : String(error));
1085
- }
1086
- }
1087
- else if (attachment.kind === "file") {
1088
- if (typeof attachment.data === "string" && attachment.data) {
1089
- files.push({ name, mimeType: mimeType ?? "application/octet-stream", size, bytes: Buffer.from(attachment.data, "base64"), truncated: !!attachment.truncated });
1090
- }
1091
- else if (typeof attachment.text === "string" && attachment.text) {
1092
- files.push({ name, mimeType: mimeType ?? "text/plain", size, text: attachment.text, truncated: !!attachment.truncated });
1093
- }
1094
- // A file with neither bytes nor text (e.g. omitted/unreadable) carries
1095
- // nothing to write, so there is nothing to hand the agent — skip it.
1096
- }
1097
- }
1098
- return { images, imageNotes, imageRefs, files };
1099
- }
1100
- /** Strip a user-supplied filename to a safe basename — no path traversal, no
1101
- * characters that would break the placeholder note or the filesystem. */
1102
- function sanitizeAttachmentFilename(name) {
1103
- const base = path
1104
- .basename(String(name || ""))
1105
- .replace(/[/\\\r\n\t[\]]/g, "_")
1106
- .replace(/^\.+/, "")
1107
- .trim()
1108
- .slice(0, 180);
1109
- return base || "attachment";
1110
- }
1111
- /**
1112
- * Write decoded file attachments into `<workdir>/.bivy-attachments/` and return
1113
- * one prose note per file (carrying the relative path) to append to the prompt.
1114
- * This is what makes an uploaded file of ANY type — binary included — readable
1115
- * by the agent's file tools. Filenames are sanitized and de-duplicated so two
1116
- * `report.pdf`s don't clobber. Best-effort: a failure degrades to a note rather
1117
- * than throwing, so a bad attachment never sinks the whole turn.
1118
- */
1119
- function materializeAttachments(record, files) {
1120
- if (!files.length)
1121
- return { note: "", refs: [] };
1122
- const refs = [];
1123
- // Store every file durably in the global content-addressed store first (for
1124
- // re-findability), independent of the per-workdir copy below. Best-effort per
1125
- // file so one bad blob doesn't lose the others.
1126
- for (const file of files) {
1127
- const bytes = file.bytes ?? (typeof file.text === "string" ? Buffer.from(file.text, "utf8") : undefined);
1128
- if (!bytes)
1129
- continue;
1130
- try {
1131
- refs.push(attachmentStore.put(bytes, { name: sanitizeAttachmentFilename(file.name), mimeType: file.mimeType, kind: "file" }));
1132
- }
1133
- catch (error) {
1134
- console.warn("[attachments] failed to store file:", error instanceof Error ? error.message : String(error));
1135
- }
1136
- }
1137
- const workdir = harnessDirFor(record);
1138
- const dir = path.join(workdir, ".bivy-attachments");
1139
- try {
1140
- fs.mkdirSync(dir, { recursive: true });
1141
- }
1142
- catch (error) {
1143
- const why = error instanceof Error ? error.message : String(error);
1144
- return { note: files.map((f) => `[File attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n"), refs };
1145
- }
1146
- const notes = [];
1147
- const used = new Set();
1148
- for (const file of files) {
1149
- const safeBase = sanitizeAttachmentFilename(file.name);
1150
- const ext = path.extname(safeBase);
1151
- const stem = safeBase.slice(0, safeBase.length - ext.length) || safeBase;
1152
- let safe = safeBase;
1153
- let n = 1;
1154
- while (used.has(safe) || fs.existsSync(path.join(dir, safe))) {
1155
- safe = `${stem}-${n}${ext}`;
1156
- n += 1;
1157
- }
1158
- used.add(safe);
1159
- const dest = path.join(dir, safe);
1160
- const label = `${safe} (${file.size ? `${file.size} bytes, ` : ""}${file.mimeType}${file.truncated ? ", truncated" : ""})`;
1161
- try {
1162
- if (file.bytes)
1163
- fs.writeFileSync(dest, file.bytes);
1164
- else if (typeof file.text === "string")
1165
- fs.writeFileSync(dest, file.text, "utf8");
1166
- else
1167
- continue;
1168
- const rel = path.relative(workdir, dest) || safe;
1169
- notes.push(`[File attachment: ${label} saved to ${rel} - read it with your file tools]`);
1170
- }
1171
- catch (error) {
1172
- notes.push(`[File attachment: ${label} could not be saved: ${error instanceof Error ? error.message : String(error)}]`);
1173
- }
1174
- }
1175
- return { note: notes.join("\n"), refs };
1176
- }
1177
1034
  /**
1178
1035
  * Store attachment bytes, persist a durable outbound reference anchored at the
1179
1036
  * current transcript position (so a reload or another device shows it), and
@@ -3000,7 +2857,7 @@ const RELAY_COMMANDS = {
3000
2857
  },
3001
2858
  async prompt(msg, ctx) {
3002
2859
  const text = String(msg.text ?? "").trim();
3003
- const { images, imageNotes, imageRefs, files } = attachmentsFrom(msg.attachments);
2860
+ const { images, imageNotes, files } = attachmentsFrom(msg.attachments);
3004
2861
  if (!text && !images.length && !files.length)
3005
2862
  return;
3006
2863
  // Title/naming can only see what we have before the session exists; the file
@@ -3043,12 +2900,12 @@ const RELAY_COMMANDS = {
3043
2900
  touchSession(record);
3044
2901
  // Now that the session (and its workdir) exists, write file attachments to
3045
2902
  // disk and fold their path notes into the prompt the agent actually sees.
3046
- const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
3047
- const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
2903
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(harnessDirFor(record), files, attachmentStore);
2904
+ const promptText = [text, fileNote].filter(Boolean).join("\n\n") ||
3048
2905
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
3049
2906
  // Persist durable attachment refs keyed by the exact text the transcript
3050
2907
  // stores for this user message, so history rehydrates thumbnails by hash.
3051
- eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
2908
+ eventLog.appendAttachments(record.id, promptText, fileRefs);
3052
2909
  const agentPrompt = promptForAgent(record, promptText);
3053
2910
  const cmid = typeof msg.clientMessageId === "string" && msg.clientMessageId ? msg.clientMessageId : undefined;
3054
2911
  // A retry of a prompt we already took: the caller missed our echo (that is
@@ -11257,7 +11114,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
11257
11114
  if (text === "/login" || text.startsWith("/login ")) {
11258
11115
  return res.status(400).json({ error: "Use the Login / API tokens dialog from the phone UI. If it is not visible, refresh this page after updating Bivy." });
11259
11116
  }
11260
- const { images, imageNotes, imageRefs, files } = attachmentsFrom(req.body?.attachments);
11117
+ const { images, imageNotes, files } = attachmentsFrom(req.body?.attachments);
11261
11118
  if (!text && !images.length && !files.length)
11262
11119
  return res.status(400).json({ error: "Missing text" });
11263
11120
  // File notes (with on-disk paths) are folded in once the workdir exists; the
@@ -11285,10 +11142,10 @@ app.post("/api/session/prompt", async (req, res, next) => {
11285
11142
  }
11286
11143
  notePresence(record.id, deviceFrom(req.body?.device), "chat");
11287
11144
  const session = record.session;
11288
- const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
11289
- const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
11145
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(harnessDirFor(record), files, attachmentStore);
11146
+ const promptText = [text, fileNote].filter(Boolean).join("\n\n") ||
11290
11147
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
11291
- eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
11148
+ eventLog.appendAttachments(record.id, promptText, fileRefs);
11292
11149
  const agentPrompt = promptForAgent(record, promptText);
11293
11150
  const cmid = typeof req.body?.clientMessageId === "string" && req.body.clientMessageId ? req.body.clientMessageId : undefined;
11294
11151
  markSessionWorking(record, { type: "agent_start" });
@@ -0,0 +1,135 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function safeAttachmentName(value) {
5
+ return String(value || "attachment").replace(/[\r\n]/g, " ").slice(0, 180);
6
+ }
7
+ /**
8
+ * Split composer attachments into channels:
9
+ * - `images` — base64 blobs passed to the model as vision.
10
+ * - `imageNotes` — short image labels for naming a new session.
11
+ * - `files` — all decoded attachments (including images) to be written to
12
+ * disk by materializeAttachments so the agent can open them
13
+ * with its normal file tools. Any file type is supported;
14
+ * binary files arrive as base64 `data`.
15
+ */
16
+ const MAX_PROMPT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
17
+ const MAX_PROMPT_ATTACHMENTS_BYTES = 40 * 1024 * 1024;
18
+ export function attachmentsFrom(value) {
19
+ if (!Array.isArray(value))
20
+ return { images: [], imageNotes: [], files: [] };
21
+ const images = [];
22
+ const imageNotes = [];
23
+ const files = [];
24
+ let totalBytes = 0;
25
+ if (value.length > 12)
26
+ throw new Error("A message can include at most 12 attachments");
27
+ for (const raw of value) {
28
+ if (!raw || typeof raw !== "object")
29
+ continue;
30
+ const attachment = raw;
31
+ const name = safeAttachmentName(attachment.name);
32
+ const size = Number(attachment.size || 0);
33
+ const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
34
+ const encodedBytes = typeof attachment.data === "string" ? Math.floor(attachment.data.length * 3 / 4) : 0;
35
+ const textBytes = attachment.kind === "file" && typeof attachment.text === "string" ? Buffer.byteLength(attachment.text) : 0;
36
+ const actualBytes = encodedBytes || textBytes;
37
+ if (actualBytes > MAX_PROMPT_ATTACHMENT_BYTES)
38
+ throw new Error(`${name} exceeds the 10 MiB attachment limit`);
39
+ totalBytes += actualBytes;
40
+ if (totalBytes > MAX_PROMPT_ATTACHMENTS_BYTES)
41
+ throw new Error("Attachments exceed the 40 MiB per-message limit");
42
+ if (attachment.kind === "image" && typeof attachment.data === "string") {
43
+ const imgMime = mimeType ?? "image/png";
44
+ images.push({ type: "image", data: attachment.data, mimeType: imgMime });
45
+ imageNotes.push(`[Image attachment: ${name}${size ? ` (${size} bytes)` : ""}]`);
46
+ files.push({ kind: "image", name, mimeType: imgMime, size, bytes: Buffer.from(attachment.data, "base64") });
47
+ }
48
+ else if (attachment.kind === "file") {
49
+ if (typeof attachment.data === "string" && attachment.data) {
50
+ files.push({ kind: "file", name, mimeType: mimeType ?? "application/octet-stream", size, bytes: Buffer.from(attachment.data, "base64"), truncated: !!attachment.truncated });
51
+ }
52
+ else if (typeof attachment.text === "string" && attachment.text) {
53
+ files.push({ kind: "file", name, mimeType: mimeType ?? "text/plain", size, text: attachment.text, truncated: !!attachment.truncated });
54
+ }
55
+ // A file with neither bytes nor text (e.g. omitted/unreadable) carries
56
+ // nothing to write, so there is nothing to hand the agent — skip it.
57
+ }
58
+ }
59
+ return { images, imageNotes, files };
60
+ }
61
+ /** Strip a user-supplied filename to a safe basename — no path traversal, no
62
+ * characters that would break the placeholder note or the filesystem. */
63
+ export function sanitizeAttachmentFilename(name) {
64
+ const base = path
65
+ .basename(String(name || ""))
66
+ .replace(/[/\\\r\n\t[\]]/g, "_")
67
+ .replace(/^\.+/, "")
68
+ .trim()
69
+ .slice(0, 180);
70
+ return base || "attachment";
71
+ }
72
+ /**
73
+ * Write decoded attachments into `<workdir>/.bivy-attachments/` and return
74
+ * one prose note per file (carrying the absolute path) to append to the prompt.
75
+ * This is what makes an uploaded file of ANY type — binary included — readable
76
+ * by the agent's file tools. Filenames are sanitized and de-duplicated so two
77
+ * `report.pdf`s don't clobber. Best-effort: a failure degrades to a note rather
78
+ * than throwing, so a bad attachment never sinks the whole turn.
79
+ */
80
+ export function materializeAttachments(workdir, files, attachmentStore) {
81
+ if (!files.length)
82
+ return { note: "", refs: [] };
83
+ const refs = [];
84
+ // Store every file durably in the global content-addressed store first (for
85
+ // re-findability), independent of the per-workdir copy below. Best-effort per
86
+ // file so one bad blob doesn't lose the others.
87
+ for (const file of files) {
88
+ const bytes = file.bytes ?? (typeof file.text === "string" ? Buffer.from(file.text, "utf8") : undefined);
89
+ if (!bytes)
90
+ continue;
91
+ try {
92
+ refs.push(attachmentStore.put(bytes, { name: sanitizeAttachmentFilename(file.name), mimeType: file.mimeType, kind: file.kind }));
93
+ }
94
+ catch (error) {
95
+ console.warn("[attachments] failed to store file:", error instanceof Error ? error.message : String(error));
96
+ }
97
+ }
98
+ const dir = path.join(workdir, ".bivy-attachments");
99
+ try {
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ }
102
+ catch (error) {
103
+ const why = error instanceof Error ? error.message : String(error);
104
+ return { note: files.map((f) => `[${f.kind === "image" ? "Image" : "File"} attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n"), refs };
105
+ }
106
+ const notes = [];
107
+ const used = new Set();
108
+ for (const file of files) {
109
+ const safeBase = sanitizeAttachmentFilename(file.name);
110
+ const ext = path.extname(safeBase);
111
+ const stem = safeBase.slice(0, safeBase.length - ext.length) || safeBase;
112
+ let safe = safeBase;
113
+ let n = 1;
114
+ while (used.has(safe) || fs.existsSync(path.join(dir, safe))) {
115
+ safe = `${stem}-${n}${ext}`;
116
+ n += 1;
117
+ }
118
+ used.add(safe);
119
+ const dest = path.join(dir, safe);
120
+ const label = `${safe} (${file.size ? `${file.size} bytes, ` : ""}${file.mimeType}${file.truncated ? ", truncated" : ""})`;
121
+ try {
122
+ if (file.bytes)
123
+ fs.writeFileSync(dest, file.bytes);
124
+ else if (typeof file.text === "string")
125
+ fs.writeFileSync(dest, file.text, "utf8");
126
+ else
127
+ continue;
128
+ notes.push(`[${file.kind === "image" ? "Image" : "File"} attachment: ${label} saved to ${dest} - read it with your file tools]`);
129
+ }
130
+ catch (error) {
131
+ notes.push(`[${file.kind === "image" ? "Image" : "File"} attachment: ${label} could not be saved: ${error instanceof Error ? error.message : String(error)}]`);
132
+ }
133
+ }
134
+ return { note: notes.join("\n"), refs };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.4-staging.2",
3
+ "version": "0.18.4-staging.4",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",