@bivy/bivy 0.20.9-staging.18 → 0.20.9-staging.19
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/agent-instructions.js +6 -4
- package/dist/server.js +9 -6
- package/dist/session/inline-image-fetch.js +21 -31
- package/dist/session/message-components.js +154 -0
- package/dist/session/transcript-persistence.js +87 -11
- package/package.json +1 -1
- package/web/assets/{AutomationsView-AIbcFsjL.js → AutomationsView-DcAETND4.js} +1 -1
- package/web/assets/{ChangesCard-CJTq-YkG.js → ChangesCard-uXyE5TTK.js} +1 -1
- package/web/assets/{LibraryView-BnGxioY4.js → LibraryView-B9w0DIbz.js} +1 -1
- package/web/assets/{ProviderConnect-DGW3ETDf.js → ProviderConnect-BkUpurE6.js} +1 -1
- package/web/assets/{Settings-DBhfUJtL.js → Settings-C2ioGLj4.js} +3 -3
- package/web/assets/{Sheet-Bi2mTD3d.js → Sheet-CZp7mDRZ.js} +1 -1
- package/web/assets/{Terminal-9Oxqgy-H.js → Terminal-BfH0WuaC.js} +1 -1
- package/web/assets/{VoiceSettings-DwRM0859.js → VoiceSettings-DRihzaAT.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-DsheO-GT.js → abnfDiagram-VCTEODGH-Cc4cW4mm.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-zhx5XEl1.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-D4nd8mdF.js → architectureDiagram-5GKGNRK7-BcKilSNa.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-DC3Tf5pB.js → blockDiagram-I7D4REHJ-BfGz5_6h.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-Dhm6lbpm.js → c4Diagram-7LVT6UL2-CJx73b5a.js} +1 -1
- package/web/assets/channel-LGWnfVAj.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-CtjRw_OF.js → chunk-4HAMMTFA-DFNP59NT.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-Cvgs20ol.js → chunk-75Z2AOVW-pSJiP9U-.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-Ejswa5gz.js → chunk-DU6HZSFF-B-vC-hPp.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-Bgcs-pB1.js → chunk-F27PBJKO-hP2MJ8hF.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-C4SPX-Pi.js → chunk-GMAD6QVW-6gntG6t6.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-CuR3kz7k.js → chunk-GVQU2GXP-DviESZZv.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-BuIaR34D.js → chunk-IMKFNOWR-P4PkcUsJ.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-wfemHyiX.js → chunk-L3NEJ4N5-CU4HAjnb.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-DXw-Domd.js → chunk-OSK3NFVY-Cwsqx7eg.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CXoSI9Me.js → chunk-P2QGCYS3-DfOWcuCT.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-BiDVbBA9.js → chunk-POPQ4Y6H-CKTpUC4i.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-D8iiAlRc.js → chunk-PWAF6VOD-DrQFMTZ-.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-BRkUQlYS.js → chunk-SHT3W25Y-Bz4VWDpy.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-BBF3dz_0.js → chunk-SVP7TREG-B0V6bnlw.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-B7Rjm_kK.js → chunk-TICWLB2K-CF6CQbpM.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-BAPaBBq4.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-BAPaBBq4.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-EgojGXg8.js → cynefin-OW5HDTMX-BhAajVvH.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-DpNMCQrq.js → cynefinDiagram-5FMLGOSQ-Bz8pRPJv.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-DUaw3zdz.js → dagre-GXQ25YYZ-Cf5k5tic.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-Bqlz3bUw.js → diagram-S7CK7UJ4-Cm6SbKgf.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-Duf1FowV.js → diagram-UQ7AKVKN-Ck9_irEy.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-O9hSm4q8.js → diagram-VSXAHHWV-BxczYAXp.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-Djk-mFNK.js → diagram-VX7I27RA-BFC68LHV.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-LKBL6j_V.js → diagram-Z3DM3KII-BDF91EvP.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-BWgI_kxI.js → ebnfDiagram-PWID7BFC-C3XMDxJ2.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-DdK-lEB6.js → erDiagram-RLTQ6QDP-9Pidgu2M.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-D_2tpS-4.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-BO-kRfDe.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-BDHNg5YZ.js → ganttDiagram-EL5Y4UJY-BWwxgZIz.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK--vFgH-zg.js → gitGraph-4MIJSDKK-B9F3Rc0d.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-BklTTyvW.js → gitGraphDiagram-WWUBYQGX-BS1GD7Wd.js} +1 -1
- package/web/assets/{index-C-3A39s0.js → index-DYjPtp50.js} +2 -2
- package/web/assets/{info-A6RAGUB7-DavXOKaT.js → info-A6RAGUB7-C-ZhGJhn.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-BS2AderJ.js → infoDiagram-27XIBGKW-D45voLxN.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-C44x2CVx.js → ishikawaDiagram-5VMMS53U-Da7uu6QG.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-CNoytc_H.js → journeyDiagram-3NMN7TZE-D5f5qfjS.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-1FvB0aw9.js → kanban-definition-UXKFOSKX-CIHcd_u_.js} +1 -1
- package/web/assets/{line-DZxVdq9l.js → line-BZMEpRsQ.js} +1 -1
- package/web/assets/{mermaid-parser.core-CmD2Biyd.js → mermaid-parser.core-C6-2ppFx.js} +3 -3
- package/web/assets/{mermaid.core-BAcbsTp4.js → mermaid.core-Biretwno.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-NYgfV-4O.js → mindmap-definition-YA3MSWOX-BcmLI9Tr.js} +1 -1
- package/web/assets/{mount-D-6T92zd.js → mount-DCpcbjMr.js} +6 -6
- package/web/assets/{packet-AYTQ26CC-WaVye30H.js → packet-AYTQ26CC-jcuXEP9Q.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-DxiDJjII.js → pegDiagram-XKGWAZYB-B8WOrEwc.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-C94IeJRs.js → pie-WAS4IAKB-CzHswRk9.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-vxmirT31.js → pieDiagram-E7YTZNPT-2wtAHtn0.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-DOk-1I4I.js → quadrantDiagram-AXDQQJYC-Kp6bmixK.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-BS-oDGL3.js → radar-RG4KPBEZ-_hFNxQaD.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-jwkk_uZz.js → railroad-74A4TZTK-Cb25bA7f.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-BSimpaOR.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-Cbx83IqN.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-MjmuuUy4.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-DjJXQ8_J.js → railroadDiagram-O6MQD6OU-Dju-xeXR.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-FulLX6GP.js → requirementDiagram-BXWQKSXE-BzwBg6Nd.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-B1aelNP7.js → sankeyDiagram-P5KCCOFB-Cdc6hrHf.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-DtuJPzpc.js → sequenceDiagram-WJ2MYXX4-MSmaqE_I.js} +1 -1
- package/web/assets/{settingsRoute-C-pYNZ5C.js → settingsRoute-D4x7Ham2.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-B0MsyUKv.js → stateDiagram-D77RDMKH-JZOSNuWe.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-BcuC5Rcb.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-CGsY4lRl.js → swimlanes-42K2YHIH-YNk5YE9M.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-B_zmK6Xl.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-CmKA6y6E.js → timeline-definition-24CTP7MA-C-3N2UxT.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-C5z4TCZn.js → treeView-Q6P3EWNA-BVnNPyeG.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-6FcPYmvm.js → treemap-WGGIJYW6-biHCw7fI.js} +1 -1
- package/web/assets/useStore-duPhU-8v.js +22 -0
- package/web/assets/{vennDiagram-4TSXK5OY-BuptxsHZ.js → vennDiagram-4TSXK5OY-C3dRkVdd.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-CvlrRIfU.js → wardley-WFR3VGLG-DJVIzuw-.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-CmLj8xNk.js → wardleyDiagram-VM6X3IG4-D8YbfjHY.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-U0hJr_dL.js → xychartDiagram-S5SC5T6Z-ezYDMmjx.js} +1 -1
- package/web/index.html +1 -1
- package/web/sw.js +1 -1
- package/web/assets/architecture-7GRP2DOG-DP4D0vrk.js +0 -1
- package/web/assets/channel-CZSQvSx8.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-DNTp8P7b.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-DNTp8P7b.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-DYbtreud.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-Dvp-Wmgq.js +0 -1
- package/web/assets/railroad-abnf-HS5TGJTU-CN-8fCIH.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-Bx2iKuZC.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-BQM_Q6KQ.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DqH0YIby.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Bi_9h0Y0.js +0 -8
- package/web/assets/useStore-Ckl0gatH.js +0 -22
|
@@ -30,10 +30,12 @@ export const MAX_AGENT_INSTRUCTIONS_BYTES = 16 * 1024;
|
|
|
30
30
|
export const BIVY_AGENT_NOTE = [
|
|
31
31
|
"You are running inside Bivy. The user follows this session in a chat app (web or phone): they cannot see your " +
|
|
32
32
|
"terminal or files you only write to disk. $BIVY_SESSION_ID identifies this session; the `bivy` commands below use it.",
|
|
33
|
-
"- Show
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
33
|
+
"- Show an IMAGE (screenshot, chart, diagram) inside your reply: write it as markdown, " +
|
|
34
|
+
"``, where you want it to appear. The path must be inside the session " +
|
|
35
|
+
"workspace; a remote `https://` image works too. No tool call needed.",
|
|
36
|
+
"- Send a FILE, or an image on its own (a report, a download they asked for): run " +
|
|
37
|
+
'`bivy attach <path> [--caption "short note"]`, or call the `attach_to_chat` tool if you have it. Same ' +
|
|
38
|
+
"workspace-only rule; files arrive as downloads.",
|
|
37
39
|
"- Something with a UI: `bivy app publish <manifest.json>` (a web server's port, a static build, a terminal) or " +
|
|
38
40
|
"`bivy app run -- <command>` (a desktop app) gives the user a live preview; `bivy app shot` screenshots it so you " +
|
|
39
41
|
"can check your work; `bivy app present` tells the user a visible change is ready to look at, and " +
|
package/dist/server.js
CHANGED
|
@@ -8160,14 +8160,17 @@ function attachSessionListeners(record) {
|
|
|
8160
8160
|
if (event.type === "turn_start" || event.type === "message_boundary" || event.type === "message_end" || event.type === "turn_end") {
|
|
8161
8161
|
transcripts.persistTranscriptSnapshot(record);
|
|
8162
8162
|
}
|
|
8163
|
-
// A finalized assistant message may reference
|
|
8164
|
-
// (``)
|
|
8165
|
-
//
|
|
8166
|
-
//
|
|
8167
|
-
//
|
|
8168
|
-
//
|
|
8163
|
+
// A finalized assistant message may reference an image via markdown — a
|
|
8164
|
+
// remote URL (``) to fetch, or a file the agent just
|
|
8165
|
+
// produced in its workspace (``) to read. Resolve and
|
|
8166
|
+
// store either now, so the chat can render it and history keeps it after the
|
|
8167
|
+
// workspace is gone. Checked on both events: message_end is the precise
|
|
8168
|
+
// "this assistant message is done" signal most runtimes emit, but turn_end
|
|
8169
|
+
// is a safety net for one that only surfaces the final text there. Both
|
|
8170
|
+
// resolvers are internally deduped, so checking twice costs nothing.
|
|
8169
8171
|
if (event.type === "message_end" || event.type === "turn_end") {
|
|
8170
8172
|
transcripts.resolveInlineImages(record);
|
|
8173
|
+
transcripts.resolveWorkspaceImages(record, harnessDirFor(record));
|
|
8171
8174
|
}
|
|
8172
8175
|
// Durably persist the throttled sidecars at the turn boundary so a crash
|
|
8173
8176
|
// loses at most the in-flight turn's UI detail, not the whole turn. A
|
|
@@ -10,25 +10,21 @@
|
|
|
10
10
|
// otherwise get the *viewer's* browser/IP to hit an arbitrary URL by embedding
|
|
11
11
|
// it in a reply).
|
|
12
12
|
//
|
|
13
|
-
// This is the PURE-ish, testable half —
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// This is the PURE-ish, testable half — host/SSRF validation and the guarded
|
|
14
|
+
// fetch itself — all dependency-injectable so tests never hit the real network
|
|
15
|
+
// or DNS. Which references a message makes at all is the renderer's and this
|
|
16
|
+
// module's shared concern, so it lives in ./message-components.js (the same file
|
|
17
|
+
// @bivy/core compiles). The server half (src/server.ts) owns the AttachmentStore
|
|
16
18
|
// write, the durable event-log ref, and the live broadcast; see resolveInlineImages.
|
|
17
19
|
import dns from "node:dns/promises";
|
|
18
20
|
import { hostnameIsLocal } from "../auth.js";
|
|
19
21
|
import { sanitizeAttachmentName, sniffMime } from "./attach-to-chat.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
const INLINE_IMAGE_MD_RE = /!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g;
|
|
28
|
-
/** Bound how many distinct remote images a single message can trigger a fetch
|
|
29
|
-
* for — a pathological/malicious message can't fan out into an unbounded
|
|
30
|
-
* number of outbound requests. */
|
|
31
|
-
export const MAX_INLINE_IMAGES_PER_MESSAGE = 6;
|
|
22
|
+
import { extractImageReferences, MAX_COMPONENTS_PER_MESSAGE } from "./message-components.js";
|
|
23
|
+
/** Bound how many distinct images a single message can trigger a resolve for —
|
|
24
|
+
* a pathological/malicious message can't fan out into an unbounded number of
|
|
25
|
+
* outbound requests or file reads. Re-exported from the shared grammar, which
|
|
26
|
+
* the renderer applies the same cap from. */
|
|
27
|
+
export const MAX_INLINE_IMAGES_PER_MESSAGE = MAX_COMPONENTS_PER_MESSAGE;
|
|
32
28
|
/** Ceiling for a single fetched inline image. Smaller than
|
|
33
29
|
* MAX_AGENT_ATTACHMENT_BYTES (attach-to-chat.ts) because these bytes come from
|
|
34
30
|
* an arbitrary, untrusted remote origin rather than the local workspace —
|
|
@@ -38,23 +34,17 @@ export const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
|
38
34
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
39
35
|
/** Hard cap on redirect hops, mirroring execEphemeralRequest's guard. */
|
|
40
36
|
const MAX_REDIRECTS = 5;
|
|
41
|
-
/**
|
|
42
|
-
* ``, in first-seen order, capped at MAX_INLINE_IMAGES_PER_MESSAGE.
|
|
37
|
+
/** The distinct `https://` URLs a message's raw markdown references via
|
|
38
|
+
* ``, in first-seen order, capped at MAX_INLINE_IMAGES_PER_MESSAGE.
|
|
39
|
+
* Workspace-relative references are a different resolver's job (they are read,
|
|
40
|
+
* not fetched) — see resolveWorkspaceImageRefs in transcript-persistence.ts. */
|
|
43
41
|
export function extractInlineImageUrls(text) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!url || seen.has(url))
|
|
51
|
-
continue;
|
|
52
|
-
seen.add(url);
|
|
53
|
-
out.push(url);
|
|
54
|
-
if (out.length >= MAX_INLINE_IMAGES_PER_MESSAGE)
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
42
|
+
return extractImageReferences(text).filter((i) => i.origin === "remote").map((i) => i.ref);
|
|
43
|
+
}
|
|
44
|
+
/** The distinct workspace-relative paths a message's raw markdown references,
|
|
45
|
+
* in first-seen order, under the same per-message cap. */
|
|
46
|
+
export function extractWorkspaceImagePaths(text) {
|
|
47
|
+
return extractImageReferences(text).filter((i) => i.origin === "workspace").map((i) => i.ref);
|
|
58
48
|
}
|
|
59
49
|
/** Best-effort plain text for an assistant RuntimeMessage's `content`, which is
|
|
60
50
|
* either a plain string or an array of typed blocks (`{type:"text", text}`
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// The grammar for things an agent places INSIDE its chat message by writing
|
|
5
|
+
// them in the markdown, rather than out-of-band beside it.
|
|
6
|
+
//
|
|
7
|
+
// Three parties have to agree on it, byte for byte:
|
|
8
|
+
//
|
|
9
|
+
// 1. the renderer (packages/core/src/markdown.ts) turns each reference into an
|
|
10
|
+
// EMPTY placeholder element carrying the reference as a data attribute;
|
|
11
|
+
// 2. the node (src/session/transcript-persistence.ts) finds the same
|
|
12
|
+
// references in the raw message, resolves each one, and stores the bytes in
|
|
13
|
+
// the content-addressed AttachmentStore under that same reference string;
|
|
14
|
+
// 3. the view (packages/web ChatView) looks the reference up in the entry's
|
|
15
|
+
// resolved map and hydrates the placeholder.
|
|
16
|
+
//
|
|
17
|
+
// Those three live in two packages that deliberately do not import each other
|
|
18
|
+
// (src/ is the node; @bivy/core is the client library). The agreement used to be
|
|
19
|
+
// two copies of one regex kept in lock-step by comment — which silently diverges
|
|
20
|
+
// the moment someone edits one. This file is the single source instead: the node
|
|
21
|
+
// compiles THIS file through a symlink at src/session/message-components.ts, and
|
|
22
|
+
// scripts/check-module-boundaries.mjs fails if the two stop being one inode.
|
|
23
|
+
//
|
|
24
|
+
// Being compiled by both builds, it imports nothing at all.
|
|
25
|
+
/** How many components one message may resolve. A pathological or hostile
|
|
26
|
+
* message must not fan out into an unbounded number of file reads / outbound
|
|
27
|
+
* fetches, nor bloat the event log that replicates to a phone. */
|
|
28
|
+
export const MAX_COMPONENTS_PER_MESSAGE = 6;
|
|
29
|
+
/** Longest reference we will even look at. Generous enough for a signed CDN URL
|
|
30
|
+
* and far past any real workspace path, but a hard bound — it is also the
|
|
31
|
+
* repetition limit in the pattern below, which is what keeps matching linear. */
|
|
32
|
+
const MAX_REF_LENGTH = 2048;
|
|
33
|
+
/** Longest alt text we will match. Bounded for the same reason, not because a
|
|
34
|
+
* caption that long means anything. */
|
|
35
|
+
const MAX_ALT_LENGTH = 500;
|
|
36
|
+
/**
|
|
37
|
+
* Markdown image syntax. One literal, used by every caller here, so the "keep
|
|
38
|
+
* these two regexes identical" hazard cannot come back.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately matches ANY target, not just the ones we accept:
|
|
41
|
+
* `classifyImageTarget` decides, and a target it rejects is left in the message
|
|
42
|
+
* as the literal text the agent wrote. Matching narrowly instead would make a
|
|
43
|
+
* rejected image silently vanish.
|
|
44
|
+
*
|
|
45
|
+
* Returned fresh per call: a shared `g`-flagged regex carries `lastIndex`
|
|
46
|
+
* between calls, which is exactly the kind of cross-call state that makes two
|
|
47
|
+
* consumers of one module disagree.
|
|
48
|
+
*
|
|
49
|
+
* Both repetitions are BOUNDED, and must stay that way. Unbounded `[^\]]*` and
|
|
50
|
+
* `[^)\s]+` make matching quadratic in the message length: on input like
|
|
51
|
+
* `![![![…` every `!` starts a match attempt that scans to the end of the
|
|
52
|
+
* string. This runs on assistant message text — which a prompt injection or a
|
|
53
|
+
* tool result echoed into a reply can shape — on the node, at every message
|
|
54
|
+
* boundary. With fixed upper bounds each attempt costs at most a constant, so
|
|
55
|
+
* the whole scan is linear. (CodeQL js/polynomial-redos flags the unbounded
|
|
56
|
+
* form; it was flagging the shape this module inherited.)
|
|
57
|
+
*/
|
|
58
|
+
function imagePattern() {
|
|
59
|
+
return new RegExp(`!\\[([^\\]]{0,${MAX_ALT_LENGTH}})\\]\\(([^)\\s]{1,${MAX_REF_LENGTH}})\\)`, "g");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reverse the entity escaping `esc()` in markdown.ts applies to the whole
|
|
63
|
+
* message before the image rule ever runs.
|
|
64
|
+
*
|
|
65
|
+
* Without this the two sides disagree for any reference containing `&`: the
|
|
66
|
+
* renderer would key on `…?a=1&b=2` while the node — which scans the raw
|
|
67
|
+
* message text — keys on `…?a=1&b=2`, and the image never hydrates. (That
|
|
68
|
+
* mismatch predates this module; it is fixed here because this is now the one
|
|
69
|
+
* place that derives a reference key.) On already-raw text this is a no-op
|
|
70
|
+
* unless the text literally contains an entity, in which case both sides
|
|
71
|
+
* normalise it the same way and still agree.
|
|
72
|
+
*/
|
|
73
|
+
export function unescapeComponentRef(target) {
|
|
74
|
+
return String(target || "")
|
|
75
|
+
.replace(/</g, "<")
|
|
76
|
+
.replace(/>/g, ">")
|
|
77
|
+
.replace(/&/g, "&");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Decide what an image target means, or `null` to refuse it.
|
|
81
|
+
*
|
|
82
|
+
* `remote` targets are fetched BY THE NODE under its SSRF checks (see
|
|
83
|
+
* src/session/inline-image-fetch.ts) — never by the viewer's browser.
|
|
84
|
+
*
|
|
85
|
+
* `workspace` targets are read from the session's working directory. This
|
|
86
|
+
* function is NOT the security boundary for them: `planAttachment()`
|
|
87
|
+
* (src/session/attach-to-chat.ts) is, because only it can resolve symlinks
|
|
88
|
+
* against the real workspace root. What this does is refuse the obviously
|
|
89
|
+
* hostile shapes up front, so an absolute path or a traversal never becomes a
|
|
90
|
+
* map key or reaches the filesystem at all. Message text is agent-authored and
|
|
91
|
+
* therefore reachable by prompt injection, so both layers earn their place.
|
|
92
|
+
*/
|
|
93
|
+
export function classifyImageTarget(target) {
|
|
94
|
+
const t = String(target || "").trim();
|
|
95
|
+
if (!t || t.length > MAX_REF_LENGTH)
|
|
96
|
+
return null;
|
|
97
|
+
if (t.startsWith("https://"))
|
|
98
|
+
return "remote";
|
|
99
|
+
// Every other scheme is refused. `http:` would be an unencrypted fetch,
|
|
100
|
+
// `data:`/`blob:` smuggle bytes past the size and mime checks, `file:` reads
|
|
101
|
+
// the node's disk outside any workspace, and `javascript:` is self-evident.
|
|
102
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(t))
|
|
103
|
+
return null;
|
|
104
|
+
if (t.startsWith("//"))
|
|
105
|
+
return null; // protocol-relative URL
|
|
106
|
+
if (t.startsWith("/") || t.startsWith("~"))
|
|
107
|
+
return null; // absolute / home-relative
|
|
108
|
+
if (t.startsWith("#"))
|
|
109
|
+
return null; // in-page anchor
|
|
110
|
+
if (t.includes("\\"))
|
|
111
|
+
return null;
|
|
112
|
+
if (t.split("/").some((segment) => segment === ".."))
|
|
113
|
+
return null; // traversal
|
|
114
|
+
// eslint-disable-next-line no-control-regex
|
|
115
|
+
if (/[\x00-\x1f\x7f]/.test(t))
|
|
116
|
+
return null;
|
|
117
|
+
return "workspace";
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Every image reference a message makes, de-duplicated, in first-seen order and
|
|
121
|
+
* capped at `limit`. Targets `classifyImageTarget` refuses are left out: they
|
|
122
|
+
* are not components, they are prose.
|
|
123
|
+
*/
|
|
124
|
+
export function extractImageReferences(text, limit = MAX_COMPONENTS_PER_MESSAGE) {
|
|
125
|
+
if (!text)
|
|
126
|
+
return [];
|
|
127
|
+
const out = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const match of String(text).matchAll(imagePattern())) {
|
|
130
|
+
const ref = unescapeComponentRef(match[2] ?? "");
|
|
131
|
+
const origin = classifyImageTarget(ref);
|
|
132
|
+
if (!origin || seen.has(ref))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(ref);
|
|
135
|
+
out.push({ ref, origin });
|
|
136
|
+
if (out.length >= limit)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Replace each accepted image reference via `render`, leaving refused ones as
|
|
143
|
+
* the literal text the agent wrote. The renderer uses this so the one pattern
|
|
144
|
+
* above stays the only place image syntax is recognised.
|
|
145
|
+
*/
|
|
146
|
+
export function replaceImageReferences(text, render) {
|
|
147
|
+
return String(text || "").replace(imagePattern(), (whole, alt, target) => {
|
|
148
|
+
const ref = unescapeComponentRef(target);
|
|
149
|
+
const origin = classifyImageTarget(ref);
|
|
150
|
+
if (!origin)
|
|
151
|
+
return whole;
|
|
152
|
+
return render({ alt: alt ?? "", ref, origin });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -17,7 +17,8 @@ import { createClientProjection } from "./client-projection.js";
|
|
|
17
17
|
import { randomBytes } from "node:crypto";
|
|
18
18
|
import { mergeBases } from "./event-log.js";
|
|
19
19
|
import { thinkingTextFromContent } from "./transcript-merge.js";
|
|
20
|
-
import { extractInlineImageUrls, fetchInlineImage, isFetchImageError, inlineImageDisplayName, assistantTextForImageScan } from "./inline-image-fetch.js";
|
|
20
|
+
import { extractInlineImageUrls, extractWorkspaceImagePaths, fetchInlineImage, isFetchImageError, inlineImageDisplayName, assistantTextForImageScan } from "./inline-image-fetch.js";
|
|
21
|
+
import { planAttachment, isAttachPlanError } from "./attach-to-chat.js";
|
|
21
22
|
import { historyDelta } from "../history-sync.js";
|
|
22
23
|
import { withToolDetails } from "../runtime/tool-call-map.js";
|
|
23
24
|
// Display-only intermediate reasoning is persisted as a length-capped HEAD so a
|
|
@@ -72,6 +73,12 @@ function thinkingTextFromEvent(event) {
|
|
|
72
73
|
return "";
|
|
73
74
|
}
|
|
74
75
|
const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
76
|
+
/** Ceiling for one workspace image copied into the transcript. Matches the
|
|
77
|
+
* remote cap (MAX_INLINE_IMAGE_BYTES) rather than the larger explicit-attach
|
|
78
|
+
* one: a `` is incidental illustration the agent spent no tool call
|
|
79
|
+
* on, and every byte is persisted in the event log and replicated to phones. An
|
|
80
|
+
* agent that means to send something big still has `bivy attach`. */
|
|
81
|
+
const MAX_WORKSPACE_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
75
82
|
export function createTranscriptPersistence(deps) {
|
|
76
83
|
const { eventLog, attachmentStore } = deps;
|
|
77
84
|
const project = createClientProjection(attachmentStore, eventLog);
|
|
@@ -93,15 +100,86 @@ export function createTranscriptPersistence(deps) {
|
|
|
93
100
|
const logged = eventLog.readBase(record.id);
|
|
94
101
|
eventLog.appendBaseSnapshot(record.id, logged.length ? mergeBases(logged, base) : base);
|
|
95
102
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
/** Every distinct reference the session's assistant messages make, via the
|
|
104
|
+
* extractor for one origin. */
|
|
105
|
+
function imageRefsInTranscript(record, extract) {
|
|
106
|
+
const refs = new Set();
|
|
107
|
+
for (const m of record.session.getMessages()) {
|
|
100
108
|
if (m.role !== "assistant")
|
|
101
109
|
continue;
|
|
102
|
-
for (const
|
|
103
|
-
|
|
110
|
+
for (const ref of extract(assistantTextForImageScan(m.content)))
|
|
111
|
+
refs.add(ref);
|
|
104
112
|
}
|
|
113
|
+
return refs;
|
|
114
|
+
}
|
|
115
|
+
/** Store resolved bytes under `ref` and tell every client, durably and live.
|
|
116
|
+
* One place, so the remote and workspace resolvers below cannot drift in how
|
|
117
|
+
* they record what they found. */
|
|
118
|
+
function recordResolvedImage(sessionId, ref, bytes, meta) {
|
|
119
|
+
const stored = attachmentStore.put(bytes, { name: meta.name, mimeType: meta.mimeType, kind: "image" });
|
|
120
|
+
eventLog.appendInlineImage(sessionId, { url: ref, ref: stored });
|
|
121
|
+
eventLog.flush(sessionId);
|
|
122
|
+
deps.broadcast(deps.stampSessionEvent({ type: "session.event", sessionId, event: { type: "inlineImage", url: ref, ref: stored } }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve markdown images an agent wrote as a path inside the session
|
|
126
|
+
* workspace (``) — the common case, since the
|
|
127
|
+
* file the agent just produced is right there and writing the markdown costs
|
|
128
|
+
* it nothing. Bytes are copied into the content-addressed AttachmentStore now,
|
|
129
|
+
* at emit time, because the transcript outlives the workspace: an ephemeral
|
|
130
|
+
* machine is destroyed, and history still has to render on a phone weeks later.
|
|
131
|
+
*
|
|
132
|
+
* Confinement is planAttachment's, exactly as for `bivy attach` — the resolved
|
|
133
|
+
* real path must sit inside the session's working directory. That matters more
|
|
134
|
+
* here than there: this path comes from message *prose*, so a prompt injection
|
|
135
|
+
* can propose one. The grammar refuses absolute paths and traversal before we
|
|
136
|
+
* get here; planAttachment is what actually proves containment, symlinks
|
|
137
|
+
* included.
|
|
138
|
+
*
|
|
139
|
+
* Synchronous (a local read, unlike a remote fetch) and resolved once per
|
|
140
|
+
* reference per session, so re-rendering history never re-reads the disk. One
|
|
141
|
+
* consequence: if the agent overwrites a file it already referenced and
|
|
142
|
+
* references the same path again, the chat keeps showing the first version.
|
|
143
|
+
* Distinct paths are the workaround until componentRefs carry a per-message
|
|
144
|
+
* anchor the way outbound attachments already do.
|
|
145
|
+
*/
|
|
146
|
+
function resolveWorkspaceImages(record, workspaceDir) {
|
|
147
|
+
if (!workspaceDir)
|
|
148
|
+
return;
|
|
149
|
+
const paths = imageRefsInTranscript(record, extractWorkspaceImagePaths);
|
|
150
|
+
if (!paths.size)
|
|
151
|
+
return;
|
|
152
|
+
const alreadyResolved = new Set(eventLog.readInlineImages(record.id).map(([ref]) => ref));
|
|
153
|
+
for (const rel of paths) {
|
|
154
|
+
if (alreadyResolved.has(rel))
|
|
155
|
+
continue;
|
|
156
|
+
const failedAt = inlineImageFailedAt.get(rel);
|
|
157
|
+
if (failedAt !== undefined && Date.now() - failedAt < INLINE_IMAGE_RETRY_COOLDOWN_MS)
|
|
158
|
+
continue;
|
|
159
|
+
const plan = planAttachment({ workspaceDir, filePath: rel, maxBytes: MAX_WORKSPACE_IMAGE_BYTES });
|
|
160
|
+
if (isAttachPlanError(plan)) {
|
|
161
|
+
console.warn(`[inline-image] ${rel}: ${plan.error}`);
|
|
162
|
+
inlineImageFailedAt.set(rel, Date.now());
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// `` is a link the agent mistyped, not an image. Refusing
|
|
166
|
+
// non-image bytes keeps a stray one from becoming an unopenable chip.
|
|
167
|
+
if (plan.kind !== "image") {
|
|
168
|
+
console.warn(`[inline-image] ${rel}: not an image (${plan.mimeType})`);
|
|
169
|
+
inlineImageFailedAt.set(rel, Date.now());
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
recordResolvedImage(record.id, rel, plan.bytes, { name: plan.name, mimeType: plan.mimeType });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.warn(`[inline-image] ${rel}:`, error instanceof Error ? error.message : String(error));
|
|
177
|
+
inlineImageFailedAt.set(rel, Date.now());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function resolveInlineImages(record) {
|
|
182
|
+
const urls = imageRefsInTranscript(record, extractInlineImageUrls);
|
|
105
183
|
if (!urls.size)
|
|
106
184
|
return;
|
|
107
185
|
const alreadyResolved = new Set(eventLog.readInlineImages(record.id).map(([url]) => url));
|
|
@@ -119,10 +197,7 @@ export function createTranscriptPersistence(deps) {
|
|
|
119
197
|
inlineImageFailedAt.set(url, Date.now());
|
|
120
198
|
return;
|
|
121
199
|
}
|
|
122
|
-
|
|
123
|
-
eventLog.appendInlineImage(record.id, { url, ref });
|
|
124
|
-
eventLog.flush(record.id);
|
|
125
|
-
deps.broadcast(deps.stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "inlineImage", url, ref } }));
|
|
200
|
+
recordResolvedImage(record.id, url, result.bytes, { name: inlineImageDisplayName(url, result.mimeType), mimeType: result.mimeType });
|
|
126
201
|
}
|
|
127
202
|
catch (error) {
|
|
128
203
|
console.warn(`[inline-image] ${url}:`, error instanceof Error ? error.message : String(error));
|
|
@@ -278,6 +353,7 @@ export function createTranscriptPersistence(deps) {
|
|
|
278
353
|
persistToolActivityFromEvent,
|
|
279
354
|
persistIntermediateFromEvent,
|
|
280
355
|
resolveInlineImages,
|
|
356
|
+
resolveWorkspaceImages,
|
|
281
357
|
conversationMessages,
|
|
282
358
|
forkMessages,
|
|
283
359
|
buildHistoryEvent,
|
package/package.json
CHANGED