@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.
- package/bin/codex-app-server-shim.mjs +6 -1
- package/dist/apps/preview-shell.js +19 -9
- package/dist/apps/styles.css +6 -0
- package/dist/apps/tokens.css +58 -51
- package/dist/server.js +10 -153
- package/dist/session/prompt-attachments.js +135 -0
- package/package.json +1 -1
|
@@ -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: [
|
|
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
|
|
54
|
-
|
|
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
|
-
<
|
|
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">
|
|
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()=>{
|
package/dist/apps/styles.css
CHANGED
|
@@ -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
|
+
}
|
package/dist/apps/tokens.css
CHANGED
|
@@ -30,37 +30,37 @@
|
|
|
30
30
|
/* ---- Core surfaces & text (neutral) ---- */
|
|
31
31
|
--bg: #ffffff;
|
|
32
32
|
--surface: #ffffff;
|
|
33
|
-
--surface-2: #
|
|
34
|
-
--surface-3: #
|
|
35
|
-
--ink: #
|
|
36
|
-
--muted: #
|
|
37
|
-
--line: #
|
|
38
|
-
--line-strong: #
|
|
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: #
|
|
43
|
+
--accent: #ac4b24;
|
|
44
44
|
--accent-contrast: #ffffff;
|
|
45
|
-
--accent-hover: #
|
|
45
|
+
--accent-hover: #873b1c;
|
|
46
46
|
--accent-active: color-mix(in srgb, var(--accent), #000 22%);
|
|
47
|
-
--accent-soft:
|
|
47
|
+
--accent-soft: #f5e5d9;
|
|
48
48
|
|
|
49
49
|
/* ---- Status ---- */
|
|
50
|
-
--danger: #
|
|
51
|
-
--ok: #
|
|
52
|
-
--merged: #
|
|
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: #
|
|
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: #
|
|
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(
|
|
73
|
-
--term-selection-inactive: rgba(
|
|
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:
|
|
110
|
-
--radius-sm:
|
|
111
|
-
--radius-md:
|
|
112
|
-
--radius-lg:
|
|
113
|
-
--radius-xl:
|
|
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
|
|
118
|
-
--shadow-md: 0 4px
|
|
119
|
-
--shadow-lg: 0 12px 32px rgba(
|
|
120
|
-
--shadow-xl: 0 24px 70px rgba(
|
|
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: #
|
|
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: #
|
|
167
|
-
--ink: #
|
|
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: #
|
|
170
|
-
--danger: #
|
|
171
|
-
--unseen: #
|
|
172
|
-
--warn: #
|
|
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: #
|
|
175
|
-
--term-selection: rgba(
|
|
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
|
|
180
|
-
--shadow-md: 0 4px
|
|
181
|
-
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.
|
|
182
|
-
--shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.
|
|
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: #
|
|
189
|
-
--ink: #
|
|
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: #
|
|
192
|
-
--danger: #
|
|
193
|
-
--unseen: #
|
|
194
|
-
--warn: #
|
|
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: #
|
|
197
|
-
--term-selection: rgba(
|
|
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
|
|
202
|
-
--shadow-md: 0 4px
|
|
203
|
-
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.
|
|
204
|
-
--shadow-xl: 0 24px 70px rgba(0, 0, 0, 0.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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