@bivy/bivy 0.20.9-staging.17 → 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.
Files changed (104) hide show
  1. package/dist/agent-instructions.js +6 -4
  2. package/dist/server.js +9 -6
  3. package/dist/session/inline-image-fetch.js +21 -31
  4. package/dist/session/message-components.js +154 -0
  5. package/dist/session/transcript-persistence.js +87 -11
  6. package/package.json +1 -1
  7. package/web/assets/{AutomationsView-CwofQV3D.js → AutomationsView-DcAETND4.js} +1 -1
  8. package/web/assets/{ChangesCard-DHzNoDXq.js → ChangesCard-uXyE5TTK.js} +1 -1
  9. package/web/assets/{LibraryView-C7LKpHpu.js → LibraryView-B9w0DIbz.js} +1 -1
  10. package/web/assets/{ProviderConnect-uYaXO3ir.js → ProviderConnect-BkUpurE6.js} +1 -1
  11. package/web/assets/{Settings-DyMVzEVl.js → Settings-C2ioGLj4.js} +3 -3
  12. package/web/assets/{Sheet-BdZM5XII.js → Sheet-CZp7mDRZ.js} +1 -1
  13. package/web/assets/{Terminal-GRJzWj5O.js → Terminal-BfH0WuaC.js} +1 -1
  14. package/web/assets/{VoiceSettings-BKjXwGif.js → VoiceSettings-DRihzaAT.js} +1 -1
  15. package/web/assets/{abnfDiagram-VCTEODGH-DRq5QFIM.js → abnfDiagram-VCTEODGH-Cc4cW4mm.js} +1 -1
  16. package/web/assets/architecture-7GRP2DOG-zhx5XEl1.js +1 -0
  17. package/web/assets/{architectureDiagram-5GKGNRK7-BbJjjpy5.js → architectureDiagram-5GKGNRK7-BcKilSNa.js} +1 -1
  18. package/web/assets/{blockDiagram-I7D4REHJ-CQiYAnLQ.js → blockDiagram-I7D4REHJ-BfGz5_6h.js} +1 -1
  19. package/web/assets/{c4Diagram-7LVT6UL2-BPh8K6mZ.js → c4Diagram-7LVT6UL2-CJx73b5a.js} +1 -1
  20. package/web/assets/channel-LGWnfVAj.js +1 -0
  21. package/web/assets/{chunk-4HAMMTFA-1qS1GKAM.js → chunk-4HAMMTFA-DFNP59NT.js} +1 -1
  22. package/web/assets/{chunk-75Z2AOVW-DF6X1muo.js → chunk-75Z2AOVW-pSJiP9U-.js} +1 -1
  23. package/web/assets/{chunk-DU6HZSFF-DN3flVEc.js → chunk-DU6HZSFF-B-vC-hPp.js} +1 -1
  24. package/web/assets/{chunk-F27PBJKO-BOZ1dYj8.js → chunk-F27PBJKO-hP2MJ8hF.js} +1 -1
  25. package/web/assets/{chunk-GMAD6QVW-DlOdHBmX.js → chunk-GMAD6QVW-6gntG6t6.js} +1 -1
  26. package/web/assets/{chunk-GVQU2GXP-hFG62wBj.js → chunk-GVQU2GXP-DviESZZv.js} +1 -1
  27. package/web/assets/{chunk-IMKFNOWR-Uyf6_EKU.js → chunk-IMKFNOWR-P4PkcUsJ.js} +1 -1
  28. package/web/assets/{chunk-L3NEJ4N5-BV7mK09E.js → chunk-L3NEJ4N5-CU4HAjnb.js} +1 -1
  29. package/web/assets/{chunk-OSK3NFVY-BQKLjEbz.js → chunk-OSK3NFVY-Cwsqx7eg.js} +1 -1
  30. package/web/assets/{chunk-P2QGCYS3-uSj-fdt7.js → chunk-P2QGCYS3-DfOWcuCT.js} +1 -1
  31. package/web/assets/{chunk-POPQ4Y6H-DQM8FB7o.js → chunk-POPQ4Y6H-CKTpUC4i.js} +1 -1
  32. package/web/assets/{chunk-PWAF6VOD-Dcy-90Wj.js → chunk-PWAF6VOD-DrQFMTZ-.js} +1 -1
  33. package/web/assets/{chunk-SHT3W25Y-DUlwdMg1.js → chunk-SHT3W25Y-Bz4VWDpy.js} +1 -1
  34. package/web/assets/{chunk-SVP7TREG-FkSWyiJu.js → chunk-SVP7TREG-B0V6bnlw.js} +1 -1
  35. package/web/assets/{chunk-TICWLB2K-CcaWZwuv.js → chunk-TICWLB2K-CF6CQbpM.js} +1 -1
  36. package/web/assets/classDiagram-ZZMXUADV-BAPaBBq4.js +1 -0
  37. package/web/assets/classDiagram-v2-VYDZK3BY-BAPaBBq4.js +1 -0
  38. package/web/assets/{cynefin-OW5HDTMX-D67dCMw-.js → cynefin-OW5HDTMX-BhAajVvH.js} +1 -1
  39. package/web/assets/{cynefinDiagram-5FMLGOSQ-C73580N2.js → cynefinDiagram-5FMLGOSQ-Bz8pRPJv.js} +1 -1
  40. package/web/assets/{dagre-GXQ25YYZ-C9zM5P65.js → dagre-GXQ25YYZ-Cf5k5tic.js} +1 -1
  41. package/web/assets/{diagram-S7CK7UJ4-KovEgiLL.js → diagram-S7CK7UJ4-Cm6SbKgf.js} +1 -1
  42. package/web/assets/{diagram-UQ7AKVKN-CoIXKo7d.js → diagram-UQ7AKVKN-Ck9_irEy.js} +1 -1
  43. package/web/assets/{diagram-VSXAHHWV-DNt0CnWP.js → diagram-VSXAHHWV-BxczYAXp.js} +1 -1
  44. package/web/assets/{diagram-VX7I27RA-DXJlPl2B.js → diagram-VX7I27RA-BFC68LHV.js} +1 -1
  45. package/web/assets/{diagram-Z3DM3KII-B88RT438.js → diagram-Z3DM3KII-BDF91EvP.js} +1 -1
  46. package/web/assets/{ebnfDiagram-PWID7BFC-CE90CnNp.js → ebnfDiagram-PWID7BFC-C3XMDxJ2.js} +1 -1
  47. package/web/assets/{erDiagram-RLTQ6QDP-BONUWvBO.js → erDiagram-RLTQ6QDP-9Pidgu2M.js} +1 -1
  48. package/web/assets/eventmodeling-NTZA5JFV-D_2tpS-4.js +1 -0
  49. package/web/assets/flowDiagram-HODETNUW-BO-kRfDe.js +1 -0
  50. package/web/assets/{ganttDiagram-EL5Y4UJY-D3v3IQrM.js → ganttDiagram-EL5Y4UJY-BWwxgZIz.js} +1 -1
  51. package/web/assets/{gitGraph-4MIJSDKK-D2jgUA_X.js → gitGraph-4MIJSDKK-B9F3Rc0d.js} +1 -1
  52. package/web/assets/{gitGraphDiagram-WWUBYQGX-Jr7Ko83B.js → gitGraphDiagram-WWUBYQGX-BS1GD7Wd.js} +1 -1
  53. package/web/assets/{index-CvbMoyna.js → index-DYjPtp50.js} +2 -2
  54. package/web/assets/{info-A6RAGUB7-C0THKOEQ.js → info-A6RAGUB7-C-ZhGJhn.js} +1 -1
  55. package/web/assets/{infoDiagram-27XIBGKW-Cdl16EeA.js → infoDiagram-27XIBGKW-D45voLxN.js} +1 -1
  56. package/web/assets/{ishikawaDiagram-5VMMS53U-C2SiJGPC.js → ishikawaDiagram-5VMMS53U-Da7uu6QG.js} +1 -1
  57. package/web/assets/{journeyDiagram-3NMN7TZE-Bo0Ach9O.js → journeyDiagram-3NMN7TZE-D5f5qfjS.js} +1 -1
  58. package/web/assets/{kanban-definition-UXKFOSKX-Dqhb20Ky.js → kanban-definition-UXKFOSKX-CIHcd_u_.js} +1 -1
  59. package/web/assets/{line-C-bnCoEx.js → line-BZMEpRsQ.js} +1 -1
  60. package/web/assets/{mermaid-parser.core-DkWkuoCg.js → mermaid-parser.core-C6-2ppFx.js} +3 -3
  61. package/web/assets/{mermaid.core-C75Mob92.js → mermaid.core-Biretwno.js} +4 -4
  62. package/web/assets/{mindmap-definition-YA3MSWOX-DKvJ7axj.js → mindmap-definition-YA3MSWOX-BcmLI9Tr.js} +1 -1
  63. package/web/assets/{mount-D44dS7_n.js → mount-DCpcbjMr.js} +6 -6
  64. package/web/assets/{packet-AYTQ26CC-aHSBpV_v.js → packet-AYTQ26CC-jcuXEP9Q.js} +1 -1
  65. package/web/assets/{pegDiagram-XKGWAZYB-B2RedppP.js → pegDiagram-XKGWAZYB-B8WOrEwc.js} +1 -1
  66. package/web/assets/{pie-WAS4IAKB-ChLi_g4D.js → pie-WAS4IAKB-CzHswRk9.js} +1 -1
  67. package/web/assets/{pieDiagram-E7YTZNPT-DNBLMt62.js → pieDiagram-E7YTZNPT-2wtAHtn0.js} +1 -1
  68. package/web/assets/{quadrantDiagram-AXDQQJYC-D_8kxA7g.js → quadrantDiagram-AXDQQJYC-Kp6bmixK.js} +1 -1
  69. package/web/assets/{radar-RG4KPBEZ-CYA8GT-1.js → radar-RG4KPBEZ-_hFNxQaD.js} +1 -1
  70. package/web/assets/{railroad-74A4TZTK-DFVh4etC.js → railroad-74A4TZTK-Cb25bA7f.js} +1 -1
  71. package/web/assets/railroad-abnf-HS5TGJTU-BSimpaOR.js +1 -0
  72. package/web/assets/railroad-ebnf-LZEXJU2U-Cbx83IqN.js +1 -0
  73. package/web/assets/railroad-peg-WCYAUIDC-MjmuuUy4.js +1 -0
  74. package/web/assets/{railroadDiagram-O6MQD6OU-B3mbBadZ.js → railroadDiagram-O6MQD6OU-Dju-xeXR.js} +1 -1
  75. package/web/assets/{requirementDiagram-BXWQKSXE-C3qESSBC.js → requirementDiagram-BXWQKSXE-BzwBg6Nd.js} +1 -1
  76. package/web/assets/{sankeyDiagram-P5KCCOFB-CFotrll2.js → sankeyDiagram-P5KCCOFB-Cdc6hrHf.js} +1 -1
  77. package/web/assets/{sequenceDiagram-WJ2MYXX4-D9NmjBAM.js → sequenceDiagram-WJ2MYXX4-MSmaqE_I.js} +1 -1
  78. package/web/assets/{settingsRoute-FsqN7sgJ.js → settingsRoute-D4x7Ham2.js} +1 -1
  79. package/web/assets/{stateDiagram-D77RDMKH-DEQC0v8I.js → stateDiagram-D77RDMKH-JZOSNuWe.js} +1 -1
  80. package/web/assets/stateDiagram-v2-MP3YSRHH-BcuC5Rcb.js +1 -0
  81. package/web/assets/{swimlanes-42K2YHIH-5F9RDDCe.js → swimlanes-42K2YHIH-YNk5YE9M.js} +1 -1
  82. package/web/assets/swimlanesDiagram-VR7AAH4N-B_zmK6Xl.js +8 -0
  83. package/web/assets/{timeline-definition-24CTP7MA-DMR2ugvM.js → timeline-definition-24CTP7MA-C-3N2UxT.js} +1 -1
  84. package/web/assets/{treeView-Q6P3EWNA-B_8UX_gs.js → treeView-Q6P3EWNA-BVnNPyeG.js} +1 -1
  85. package/web/assets/{treemap-WGGIJYW6-CkxH7O5U.js → treemap-WGGIJYW6-biHCw7fI.js} +1 -1
  86. package/web/assets/useStore-duPhU-8v.js +22 -0
  87. package/web/assets/{vennDiagram-4TSXK5OY-hxCsdnnv.js → vennDiagram-4TSXK5OY-C3dRkVdd.js} +1 -1
  88. package/web/assets/{wardley-WFR3VGLG--nKSUnSV.js → wardley-WFR3VGLG-DJVIzuw-.js} +1 -1
  89. package/web/assets/{wardleyDiagram-VM6X3IG4-C7HzXDvi.js → wardleyDiagram-VM6X3IG4-D8YbfjHY.js} +1 -1
  90. package/web/assets/{xychartDiagram-S5SC5T6Z-D7xgBqsY.js → xychartDiagram-S5SC5T6Z-ezYDMmjx.js} +1 -1
  91. package/web/index.html +1 -1
  92. package/web/sw.js +1 -1
  93. package/web/assets/architecture-7GRP2DOG-BYFFMi3M.js +0 -1
  94. package/web/assets/channel-CDWSCCoF.js +0 -1
  95. package/web/assets/classDiagram-ZZMXUADV-vt0CkOS7.js +0 -1
  96. package/web/assets/classDiagram-v2-VYDZK3BY-vt0CkOS7.js +0 -1
  97. package/web/assets/eventmodeling-NTZA5JFV-DdMcq6ez.js +0 -1
  98. package/web/assets/flowDiagram-HODETNUW-4ZJXqhga.js +0 -1
  99. package/web/assets/railroad-abnf-HS5TGJTU-BrX9L9WA.js +0 -1
  100. package/web/assets/railroad-ebnf-LZEXJU2U-DF-WC-yF.js +0 -1
  101. package/web/assets/railroad-peg-WCYAUIDC-MMg6IooL.js +0 -1
  102. package/web/assets/stateDiagram-v2-MP3YSRHH-CSUSyqDs.js +0 -1
  103. package/web/assets/swimlanesDiagram-VR7AAH4N-CElAbdxj.js +0 -8
  104. package/web/assets/useStore-WMULDPzn.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 a LOCAL file or image (report, screenshot, chart, a file they asked for): run " +
34
- '`bivy attach <path> [--caption "short note"]`, or call the `attach_to_chat` tool if you have it. Images render ' +
35
- "inline, other files as downloads; the path must be inside the session workspace. Markdown image syntax like " +
36
- "![](path) does not render a local path. A REMOTE `https://` image in markdown does render.",
33
+ "- Show an IMAGE (screenshot, chart, diagram) inside your reply: write it as markdown, " +
34
+ "`![short caption](relative/path.png)`, 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 a remote image via markdown
8164
- // (`![alt](https://…)`) — fetch and store it now so the chat can render it
8165
- // (see resolveInlineImages). Checked on both events: message_end is the
8166
- // precise "this assistant message is done" signal most runtimes emit, but
8167
- // turn_end is a safety net for one that only surfaces the final text there.
8168
- // Fire-and-forget and internally deduped, so checking on both costs nothing.
8163
+ // A finalized assistant message may reference an image via markdown — a
8164
+ // remote URL (`![alt](https://…)`) to fetch, or a file the agent just
8165
+ // produced in its workspace (`![alt](out/chart.png)`) 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 — URL extraction, host/SSRF validation,
14
- // and the guarded fetch itself — all dependency-injectable so tests never hit
15
- // the real network or DNS. The server half (src/server.ts) owns the AttachmentStore
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
- * The image-markdown pattern, `![alt](https://…)` — MUST match the image regex
22
- * in `inline()` in packages/core/src/markdown.ts exactly. Not a shared import: the
23
- * node (src/) intentionally does not depend on @bivy/core (a browser/client
24
- * package — see packages/core's own description). Kept in lock-step by comment
25
- * instead: if you change one, change the other.
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
- /** Extract the distinct `https://` URLs a message's raw markdown references via
42
- * `![alt](url)`, in first-seen order, capped at MAX_INLINE_IMAGES_PER_MESSAGE. */
37
+ /** The distinct `https://` URLs a message's raw markdown references via
38
+ * `![alt](url)`, 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
- if (!text)
45
- return [];
46
- const out = [];
47
- const seen = new Set();
48
- for (const match of text.matchAll(INLINE_IMAGE_MD_RE)) {
49
- const url = match[1];
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&amp;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(/&lt;/g, "<")
76
+ .replace(/&gt;/g, ">")
77
+ .replace(/&amp;/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 `![…](path)` 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
- function resolveInlineImages(record) {
97
- const messages = record.session.getMessages();
98
- const urls = new Set();
99
- for (const m of messages) {
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 url of extractInlineImageUrls(assistantTextForImageScan(m.content)))
103
- urls.add(url);
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 (`![Coverage](out/coverage.png)`) — 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
+ // `![…](notes.md)` 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
- const ref = attachmentStore.put(result.bytes, { name: inlineImageDisplayName(url, result.mimeType), mimeType: result.mimeType, kind: "image" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.20.9-staging.17",
3
+ "version": "0.20.9-staging.19",
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.",