@bivy/bivy 0.20.9-staging.2 → 0.20.9-staging.21

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 (131) hide show
  1. package/README.md +6 -5
  2. package/bin/bivy.mjs +13 -56
  3. package/bin/cli-commands.mjs +2 -4
  4. package/bin/guides/talk-to-the-user.md +13 -24
  5. package/dist/agent-instructions.js +12 -14
  6. package/dist/agents/pi/integration.js +1 -1
  7. package/dist/apps/remote-preview.js +3 -0
  8. package/dist/apps/service.js +6 -1
  9. package/dist/apps/styles.css +40 -102
  10. package/dist/automation-reseal.js +30 -0
  11. package/dist/certification/generated.js +1 -1
  12. package/dist/device-registry.js +27 -0
  13. package/dist/ephemeral-teardown.js +11 -1
  14. package/dist/exec.js +4 -1
  15. package/dist/runtime/model-catalog.js +16 -0
  16. package/dist/server.js +73 -62
  17. package/dist/session/event-log.js +98 -41
  18. package/dist/session/inline-image-fetch.js +24 -33
  19. package/dist/session/message-components.js +323 -0
  20. package/dist/session/run-terminal.js +9 -7
  21. package/dist/session/session-tokens.js +1 -1
  22. package/dist/session/transcript-persistence.js +106 -11
  23. package/package.json +6 -6
  24. package/web/assets/AutomationsView-Cobo4Kep.js +51 -0
  25. package/web/assets/{ChangesCard-DcSRy5g_.js → ChangesCard-Bo6E8C_y.js} +3 -3
  26. package/web/assets/LibraryView-BTeMc6BO.js +2 -0
  27. package/web/assets/{ProviderConnect-cpGwBMrU.js → ProviderConnect-CsidS7kq.js} +1 -1
  28. package/web/assets/SessionList-B7IbeXSa.js +1 -0
  29. package/web/assets/Settings-BQRQsQ36.js +10 -0
  30. package/web/assets/{Sheet-D-5t3lJ0.js → Sheet-C0BIPbMr.js} +1 -1
  31. package/web/assets/{Terminal-CjMjKB1f.js → Terminal-BTRgonB5.js} +1 -1
  32. package/web/assets/{VoiceSettings-xv6rjaY2.js → VoiceSettings-UZ1SA0lQ.js} +1 -1
  33. package/web/assets/{abnfDiagram-VCTEODGH-BLT3MLyK.js → abnfDiagram-VCTEODGH-CJoWsvVk.js} +1 -1
  34. package/web/assets/architecture-7GRP2DOG-CuzcodTO.js +1 -0
  35. package/web/assets/{architectureDiagram-5GKGNRK7-BGinLETW.js → architectureDiagram-5GKGNRK7-_qpa4O1v.js} +1 -1
  36. package/web/assets/{blockDiagram-I7D4REHJ-BNiY2HLB.js → blockDiagram-I7D4REHJ-DmLgwSr-.js} +1 -1
  37. package/web/assets/{c4Diagram-7LVT6UL2-46aGfRMd.js → c4Diagram-7LVT6UL2-DeZuOOSk.js} +1 -1
  38. package/web/assets/channel-B0OL-aPd.js +1 -0
  39. package/web/assets/{chunk-4HAMMTFA-CRzer-vm.js → chunk-4HAMMTFA-Cr6O6lBG.js} +1 -1
  40. package/web/assets/{chunk-75Z2AOVW-MFkgf2Ar.js → chunk-75Z2AOVW-gSgNIYMY.js} +1 -1
  41. package/web/assets/{chunk-DU6HZSFF-Bpze6ID8.js → chunk-DU6HZSFF-BacnRf2Y.js} +1 -1
  42. package/web/assets/{chunk-F27PBJKO-Dq3OmNXG.js → chunk-F27PBJKO-DRrpfF1z.js} +1 -1
  43. package/web/assets/{chunk-GMAD6QVW-umDWdG9g.js → chunk-GMAD6QVW-DXCi6v6B.js} +1 -1
  44. package/web/assets/{chunk-GVQU2GXP-DuT1Awt9.js → chunk-GVQU2GXP-Yd74hIeT.js} +1 -1
  45. package/web/assets/{chunk-IMKFNOWR-CpFFccVb.js → chunk-IMKFNOWR-D5xz0-QC.js} +1 -1
  46. package/web/assets/{chunk-L3NEJ4N5-0jRWrASa.js → chunk-L3NEJ4N5-BrS0CGPM.js} +1 -1
  47. package/web/assets/{chunk-OSK3NFVY-CBErW4-p.js → chunk-OSK3NFVY-C6d1DSJe.js} +1 -1
  48. package/web/assets/{chunk-P2QGCYS3-BPMii7LM.js → chunk-P2QGCYS3-BrKgIngm.js} +1 -1
  49. package/web/assets/{chunk-POPQ4Y6H-CHTRMb_C.js → chunk-POPQ4Y6H-Bkwx5BZs.js} +1 -1
  50. package/web/assets/{chunk-PWAF6VOD-BOcNKrmu.js → chunk-PWAF6VOD-AeCCeE0F.js} +1 -1
  51. package/web/assets/{chunk-SHT3W25Y-Dvb-oEDN.js → chunk-SHT3W25Y-01NYjozy.js} +1 -1
  52. package/web/assets/{chunk-SVP7TREG-B4AQIUTK.js → chunk-SVP7TREG-CCh7wXAI.js} +1 -1
  53. package/web/assets/{chunk-TICWLB2K-CWzLTsHW.js → chunk-TICWLB2K-CcWKqwdM.js} +1 -1
  54. package/web/assets/classDiagram-ZZMXUADV-D8dSdFQt.js +1 -0
  55. package/web/assets/classDiagram-v2-VYDZK3BY-D8dSdFQt.js +1 -0
  56. package/web/assets/{cynefin-OW5HDTMX-BPkKT48j.js → cynefin-OW5HDTMX-CYYc_tBE.js} +1 -1
  57. package/web/assets/{cynefinDiagram-5FMLGOSQ-Ck2qLmpR.js → cynefinDiagram-5FMLGOSQ-Dnz7OfxK.js} +1 -1
  58. package/web/assets/{dagre-GXQ25YYZ-BPKEs_49.js → dagre-GXQ25YYZ-di5CyaMt.js} +1 -1
  59. package/web/assets/{diagram-S7CK7UJ4-ou-L5HdJ.js → diagram-S7CK7UJ4-BVC93OLB.js} +1 -1
  60. package/web/assets/{diagram-UQ7AKVKN-DihVn6fF.js → diagram-UQ7AKVKN-D7pXHAvQ.js} +1 -1
  61. package/web/assets/{diagram-VSXAHHWV-BkLtxbP7.js → diagram-VSXAHHWV-d0zW-IUz.js} +1 -1
  62. package/web/assets/{diagram-VX7I27RA-BZ-Z-CwP.js → diagram-VX7I27RA-BRlMo9oa.js} +1 -1
  63. package/web/assets/{diagram-Z3DM3KII-BQN-bGqD.js → diagram-Z3DM3KII-BmfntB1i.js} +1 -1
  64. package/web/assets/{ebnfDiagram-PWID7BFC-aKK1a_it.js → ebnfDiagram-PWID7BFC-BxUYvqsz.js} +1 -1
  65. package/web/assets/{erDiagram-RLTQ6QDP-BGsXtJl-.js → erDiagram-RLTQ6QDP-CbUl1Cqa.js} +1 -1
  66. package/web/assets/eventmodeling-NTZA5JFV-sTVSUyNU.js +1 -0
  67. package/web/assets/flowDiagram-HODETNUW-Beo3zBEU.js +1 -0
  68. package/web/assets/{ganttDiagram-EL5Y4UJY-lrYuyNAe.js → ganttDiagram-EL5Y4UJY-CkpR0avQ.js} +1 -1
  69. package/web/assets/{gitGraph-4MIJSDKK-raj_IxZ8.js → gitGraph-4MIJSDKK-CmbJxSaM.js} +1 -1
  70. package/web/assets/{gitGraphDiagram-WWUBYQGX-DzqRGXuk.js → gitGraphDiagram-WWUBYQGX-iyO408KF.js} +1 -1
  71. package/web/assets/index-B4HaCD-n.css +1 -0
  72. package/web/assets/{index-B6GPr2Pt.js → index-fdEPLcPs.js} +2 -2
  73. package/web/assets/{info-A6RAGUB7-DJVi9SAN.js → info-A6RAGUB7--6JCM0xX.js} +1 -1
  74. package/web/assets/{infoDiagram-27XIBGKW-B2HrGywP.js → infoDiagram-27XIBGKW-Dchx6QcB.js} +1 -1
  75. package/web/assets/{ishikawaDiagram-5VMMS53U-CczIkvWw.js → ishikawaDiagram-5VMMS53U-DOY3izUi.js} +1 -1
  76. package/web/assets/{journeyDiagram-3NMN7TZE-D-h-kwep.js → journeyDiagram-3NMN7TZE-oqZgC9oJ.js} +1 -1
  77. package/web/assets/{kanban-definition-UXKFOSKX-GClrFWfH.js → kanban-definition-UXKFOSKX-HCRC0D9X.js} +1 -1
  78. package/web/assets/{line-pweWV0oc.js → line-qcY2eok7.js} +1 -1
  79. package/web/assets/{mermaid-parser.core-C1vHZCtC.js → mermaid-parser.core-Bullp1xl.js} +3 -3
  80. package/web/assets/{mermaid.core-Df8DJ7AE.js → mermaid.core-FBmsAtMC.js} +4 -4
  81. package/web/assets/{mindmap-definition-YA3MSWOX-BpqA307n.js → mindmap-definition-YA3MSWOX-CfgOAkVQ.js} +1 -1
  82. package/web/assets/mount-IY4-2elJ.js +31 -0
  83. package/web/assets/{packet-AYTQ26CC-afE6T0Hc.js → packet-AYTQ26CC-COAmiopo.js} +1 -1
  84. package/web/assets/{pegDiagram-XKGWAZYB-AAEw7Khy.js → pegDiagram-XKGWAZYB-BYafVbeG.js} +1 -1
  85. package/web/assets/{pie-WAS4IAKB-vtwSUVVK.js → pie-WAS4IAKB-ZYe346n6.js} +1 -1
  86. package/web/assets/{pieDiagram-E7YTZNPT-CDg0SkhS.js → pieDiagram-E7YTZNPT-DnRbaA7i.js} +1 -1
  87. package/web/assets/{quadrantDiagram-AXDQQJYC-_o0rOR26.js → quadrantDiagram-AXDQQJYC-B2uxyKLd.js} +1 -1
  88. package/web/assets/{radar-RG4KPBEZ-_Tf7tpz8.js → radar-RG4KPBEZ-DwFlqYgB.js} +1 -1
  89. package/web/assets/{railroad-74A4TZTK-DM_a0HtL.js → railroad-74A4TZTK-DBv5jh9o.js} +1 -1
  90. package/web/assets/railroad-abnf-HS5TGJTU-VQxaaUVU.js +1 -0
  91. package/web/assets/railroad-ebnf-LZEXJU2U-DWKO1s90.js +1 -0
  92. package/web/assets/railroad-peg-WCYAUIDC-DvViPquF.js +1 -0
  93. package/web/assets/{railroadDiagram-O6MQD6OU-BpQZeQ-f.js → railroadDiagram-O6MQD6OU-CIUFwDWd.js} +1 -1
  94. package/web/assets/{requirementDiagram-BXWQKSXE-DC4US4K_.js → requirementDiagram-BXWQKSXE-BXwDlf9Q.js} +1 -1
  95. package/web/assets/{sankeyDiagram-P5KCCOFB-BUJ9KIqV.js → sankeyDiagram-P5KCCOFB-DTiRcy5E.js} +1 -1
  96. package/web/assets/{sequenceDiagram-WJ2MYXX4-By7D5XFY.js → sequenceDiagram-WJ2MYXX4-DFPRDr4a.js} +1 -1
  97. package/web/assets/settingsRoute-3489vHS0.js +1 -0
  98. package/web/assets/{stateDiagram-D77RDMKH-D0BA3EeM.js → stateDiagram-D77RDMKH-CFQ-4xlY.js} +1 -1
  99. package/web/assets/stateDiagram-v2-MP3YSRHH-DIIQkE2r.js +1 -0
  100. package/web/assets/{swimlanes-42K2YHIH-BNUI3ghm.js → swimlanes-42K2YHIH-Bke6VXs0.js} +1 -1
  101. package/web/assets/swimlanesDiagram-VR7AAH4N-Drds9YUy.js +8 -0
  102. package/web/assets/{timeline-definition-24CTP7MA-DVzeoz4F.js → timeline-definition-24CTP7MA-Cs2-dpWN.js} +1 -1
  103. package/web/assets/{treeView-Q6P3EWNA-CnvdZw-U.js → treeView-Q6P3EWNA-Dr8vsD3U.js} +1 -1
  104. package/web/assets/{treemap-WGGIJYW6-Ax_RXzPk.js → treemap-WGGIJYW6-DbvC3nsA.js} +1 -1
  105. package/web/assets/useStore-BDEjghiw.js +23 -0
  106. package/web/assets/{vennDiagram-4TSXK5OY-CNauH8S4.js → vennDiagram-4TSXK5OY-CdUoLWPo.js} +1 -1
  107. package/web/assets/{wardley-WFR3VGLG-q8WnmSMu.js → wardley-WFR3VGLG-BL7j6fb-.js} +1 -1
  108. package/web/assets/{wardleyDiagram-VM6X3IG4-DKOY4P11.js → wardleyDiagram-VM6X3IG4-BEVYmHWI.js} +1 -1
  109. package/web/assets/{xychartDiagram-S5SC5T6Z-m8KaPfml.js → xychartDiagram-S5SC5T6Z-C9zn_1JB.js} +1 -1
  110. package/web/index.html +2 -2
  111. package/web/sw.js +1 -1
  112. package/dist/ephemeral-exec.js +0 -108
  113. package/dist/session/suggestions.js +0 -17
  114. package/web/assets/AutomationsView-DEild20v.js +0 -51
  115. package/web/assets/LibraryView-Cu0tU0gU.js +0 -2
  116. package/web/assets/Settings-BoTU83-i.js +0 -10
  117. package/web/assets/architecture-7GRP2DOG-s28sMQ0-.js +0 -1
  118. package/web/assets/channel-CPSM7rj7.js +0 -1
  119. package/web/assets/classDiagram-ZZMXUADV-CZ-So67n.js +0 -1
  120. package/web/assets/classDiagram-v2-VYDZK3BY-CZ-So67n.js +0 -1
  121. package/web/assets/eventmodeling-NTZA5JFV-Dd2mk6iz.js +0 -1
  122. package/web/assets/flowDiagram-HODETNUW-D7f1jYIL.js +0 -1
  123. package/web/assets/index-D76QD5dl.css +0 -1
  124. package/web/assets/mount-ISRAcaLu.js +0 -33
  125. package/web/assets/railroad-abnf-HS5TGJTU-BzFZIgti.js +0 -1
  126. package/web/assets/railroad-ebnf-LZEXJU2U-BkKCtTE4.js +0 -1
  127. package/web/assets/railroad-peg-WCYAUIDC-CuXWl9dG.js +0 -1
  128. package/web/assets/settingsRoute-DSj-6Pdo.js +0 -1
  129. package/web/assets/stateDiagram-v2-MP3YSRHH-2_bDqGCB.js +0 -1
  130. package/web/assets/swimlanesDiagram-VR7AAH4N-SmXx3nAj.js +0 -8
  131. package/web/assets/useStore-BznE40dl.js +0 -29
@@ -17,10 +17,12 @@
17
17
  // text). The base is a SNAPSHOT that is overwritten each turn and SHRINKS on
18
18
  // compaction, so it cannot be appended naively. Instead each snapshot is stored
19
19
  // as a bounded DELTA against the previous one: a `reset:false` record carrying
20
- // only the new tail when the snapshot merely extends the last (the common case),
21
- // or a `reset:true` record carrying the full snapshot when it shrinks or a
22
- // prefix message changed (compaction / in-place mutation). `baseReplay` folds
23
- // these back — reset replaces, non-reset extends — reproducing the last snapshot
20
+ // only the new tail when the snapshot merely extends the last (the common case)
21
+ // or changes after a shared prefix (`keep` = how much of the prefix survives;
22
+ // a retried turn replacing its trailing error), or a `reset:true` record
23
+ // carrying the full snapshot when its first message changed (compaction).
24
+ // `baseReplay` folds these back — reset replaces, non-reset truncates to `keep`
25
+ // and extends — reproducing the last snapshot
24
26
  // exactly, so the fold is behaviour-preserving while keeping the file bounded
25
27
  // (~O(total messages)) instead of O(turns × transcript size).
26
28
  //
@@ -31,7 +33,6 @@ import path from "node:path";
31
33
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
32
34
  import { DELEGATION_BLOCK, isDelegationCard } from "./delegations.js";
33
35
  import { APP_PIN_BLOCK, APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppPin, isAppReference, isAppReview } from "../apps/types.js";
34
- import { SUGGESTION_BLOCK, isTaskSuggestion } from "./suggestions.js";
35
36
  import { NOTICE_BLOCK, isAgentNotice } from "./notices.js";
36
37
  /**
37
38
  * Decompose a transcript message into serialization-independent "atoms": the
@@ -292,7 +293,9 @@ function isBase(value) {
292
293
  if (!value || typeof value !== "object")
293
294
  return false;
294
295
  const record = value;
295
- return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean";
296
+ const keep = value.keep;
297
+ return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean"
298
+ && (keep === undefined || (Number.isInteger(keep) && keep >= 0));
296
299
  }
297
300
  function isForkDisplay(value) {
298
301
  if (!value || typeof value !== "object")
@@ -333,12 +336,6 @@ function isAppPublication(value) {
333
336
  const entry = value;
334
337
  return entry.bivyKind === "app-publication" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReference(entry.app);
335
338
  }
336
- function isSuggestionEntry(value) {
337
- if (!value || typeof value !== "object")
338
- return false;
339
- const entry = value;
340
- return entry.bivyKind === "suggestion" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isTaskSuggestion(entry.suggestion);
341
- }
342
339
  function isNoticeEntry(value) {
343
340
  if (!value || typeof value !== "object")
344
341
  return false;
@@ -364,7 +361,7 @@ function isAppPinEntry(value) {
364
361
  return entry.bivyKind === "app-pin" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppPin(entry.pin);
365
362
  }
366
363
  function isRecord(value) {
367
- return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isSuggestionEntry(value) || isNoticeEntry(value);
364
+ return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isNoticeEntry(value);
368
365
  }
369
366
  /**
370
367
  * Fold attachment records into a text→refs list: last write wins per text (a
@@ -507,15 +504,11 @@ export function replayExtras(entries) {
507
504
  role: "assistant", content: [{ type: DELEGATION_BLOCK, delegation: entry.delegation }],
508
505
  id: `delegation-${entry.id}`, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
509
506
  }));
510
- const suggestions = entries.filter((entry) => entry.bivyKind === "suggestion").map((entry) => ({
511
- role: "assistant", content: [{ type: SUGGESTION_BLOCK, suggestion: entry.suggestion }],
512
- id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
513
- }));
514
507
  const notices = entries.filter((entry) => entry.bivyKind === "notice").map((entry) => ({
515
508
  role: "assistant", content: [{ type: NOTICE_BLOCK, notice: entry.notice }],
516
509
  id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
517
510
  }));
518
- return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...pinCards, ...delegationCards, ...suggestions, ...notices];
511
+ return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...pinCards, ...delegationCards, ...notices];
519
512
  }
520
513
  /**
521
514
  * Fold the outbound (agent-sent) attachment records into time-anchored synthetic
@@ -555,34 +548,70 @@ export function baseReplay(entries) {
555
548
  if (entry.reset)
556
549
  base = [...entry.messages];
557
550
  else
558
- base = base.concat(entry.messages);
551
+ base = (entry.keep === undefined ? base : base.slice(0, entry.keep)).concat(entry.messages);
559
552
  }
560
553
  return base;
561
554
  }
562
- function parseLogDetailed(body, keep = () => true) {
563
- const records = [];
555
+ function parseLogDetailed(lines, keep = () => true) {
556
+ let records = [];
564
557
  let malformedLines = 0;
565
- for (const line of body.split("\n")) {
558
+ let superseded = 0;
559
+ for (const line of lines) {
566
560
  const trimmed = line.trim();
567
561
  if (!trimmed || !keep(trimmed))
568
562
  continue;
569
563
  try {
570
564
  const value = JSON.parse(trimmed);
571
- if (isRecord(value))
572
- records.push(value);
573
- else
565
+ if (!isRecord(value)) {
574
566
  malformedLines += 1;
567
+ continue;
568
+ }
569
+ // A full reset replaces every earlier base record (baseReplay discards
570
+ // them), so don't hold old snapshots and their images in memory.
571
+ if (isBase(value) && value.reset) {
572
+ const before = records.length;
573
+ records = records.filter((record) => record.bivyKind !== "base");
574
+ superseded += before - records.length;
575
+ }
576
+ records.push(value);
575
577
  }
576
578
  catch {
577
579
  malformedLines += 1;
578
580
  }
579
581
  }
580
- return { records, malformedLines };
582
+ return { records, malformedLines, superseded };
583
+ }
584
+ /** A log this large that carries replaced base snapshots is rewritten without
585
+ * them when it's next read. */
586
+ const COMPACT_LOG_BYTES = 32 * 1024 * 1024;
587
+ /** A file's lines, read in chunks: a log can outgrow the longest string V8
588
+ * allows (~512 MB), which made the whole session unreadable. */
589
+ function* fileLines(file) {
590
+ const fd = fs.openSync(file, "r");
591
+ try {
592
+ const chunk = Buffer.allocUnsafe(4 * 1024 * 1024);
593
+ let partial = [];
594
+ for (let n = fs.readSync(fd, chunk); n > 0; n = fs.readSync(fd, chunk)) {
595
+ let start = 0;
596
+ for (let nl = chunk.indexOf(10, start); nl !== -1 && nl < n; nl = chunk.indexOf(10, start)) {
597
+ yield Buffer.concat([...partial, chunk.subarray(start, nl)]).toString("utf8");
598
+ partial = [];
599
+ start = nl + 1;
600
+ }
601
+ if (start < n)
602
+ partial.push(Buffer.from(chunk.subarray(start, n)));
603
+ }
604
+ if (partial.length)
605
+ yield Buffer.concat(partial).toString("utf8");
606
+ }
607
+ finally {
608
+ fs.closeSync(fd);
609
+ }
581
610
  }
582
611
  /** Parse a JSONL log body into valid records. Callers that need corruption
583
612
  * diagnostics use EventLog.load, which reports malformed lines via onIssue. */
584
613
  export function parseLog(body) {
585
- return parseLogDetailed(body).records;
614
+ return parseLogDetailed(body.split("\n")).records;
586
615
  }
587
616
  /**
588
617
  * Append-only, coalesced, throttled JSONL log store — the write+read companion to
@@ -666,11 +695,14 @@ export class EventLog {
666
695
  /** Read + parse a session's log file (lines passing `keep`), reporting problems. */
667
696
  readLog(id, keep) {
668
697
  try {
669
- const body = fs.readFileSync(this.pathFor(id), "utf8");
670
- const parsed = parseLogDetailed(body, keep);
698
+ const file = this.pathFor(id);
699
+ const bytes = fs.statSync(file).size;
700
+ const parsed = parseLogDetailed(fileLines(file), keep);
671
701
  if (parsed.malformedLines > 0)
672
702
  this.report(id, "parse", new Error(`${parsed.malformedLines} malformed record(s); valid history was recovered`));
673
- return { records: parsed.records, bytes: body.length };
703
+ if (!keep && parsed.superseded > 0 && bytes > COMPACT_LOG_BYTES)
704
+ return { records: parsed.records, bytes: this.compact(id, parsed.records) ?? bytes };
705
+ return { records: parsed.records, bytes };
674
706
  }
675
707
  catch (error) {
676
708
  if (error?.code !== "ENOENT")
@@ -678,6 +710,31 @@ export class EventLog {
678
710
  return { records: [], bytes: 0 };
679
711
  }
680
712
  }
713
+ /** Rewrite a log without the base snapshots a later reset replaced (logs
714
+ * written before truncate-and-extend records repeated the whole transcript on
715
+ * every retry). Atomic; returns the new size, or undefined if it failed. */
716
+ compact(id, records) {
717
+ const file = this.pathFor(id);
718
+ const tmp = `${file}.compact-${process.pid}`;
719
+ try {
720
+ const fd = fs.openSync(tmp, "w");
721
+ let bytes = 0;
722
+ try {
723
+ for (const record of records)
724
+ bytes += fs.writeSync(fd, `${JSON.stringify(record)}\n`);
725
+ }
726
+ finally {
727
+ fs.closeSync(fd);
728
+ }
729
+ fs.renameSync(tmp, file);
730
+ return bytes;
731
+ }
732
+ catch (error) {
733
+ fs.rmSync(tmp, { force: true });
734
+ this.report(id, "rewrite", error);
735
+ return undefined;
736
+ }
737
+ }
681
738
  load(id) {
682
739
  const cached = this.disk.get(id);
683
740
  if (cached) {
@@ -773,15 +830,19 @@ export class EventLog {
773
830
  // Identical snapshot → nothing to record.
774
831
  if (nextKeys.length === prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]))
775
832
  return;
776
- // A prefix-EXTEND (strictly longer, every prior message unchanged) appends only
777
- // the new tail; anything else — a shrink (compaction), a changed prefix message
778
- // (in-place mutation), or the first snapshot (empty prev, nothing to extend) —
833
+ // Record only what follows the prefix both snapshots share: a plain extend, or
834
+ // a truncate-and-extend (`keep`) when a later message changed — a retried turn
835
+ // replacing its trailing error rewrote the whole transcript, images and all,
836
+ // on every retry. A changed first message (compaction) or the first snapshot
779
837
  // appends a full reset. Rebuild the recorded messages from the serialized form:
780
838
  // a free deep copy that also matches exactly what will land on disk.
781
- const extend = prevKeys.length > 0 && nextKeys.length > prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]);
782
- const record = extend
783
- ? { bivyKind: "base", reset: false, createdAt: Date.now(), messages: nextKeys.slice(prevKeys.length).map((s) => JSON.parse(s)) }
784
- : { bivyKind: "base", reset: true, createdAt: Date.now(), messages: nextKeys.map((s) => JSON.parse(s)) };
839
+ let shared = 0;
840
+ while (shared < prevKeys.length && shared < nextKeys.length && prevKeys[shared] === nextKeys[shared])
841
+ shared++;
842
+ const tail = nextKeys.slice(shared).map((s) => JSON.parse(s));
843
+ const record = shared === 0
844
+ ? { bivyKind: "base", reset: true, createdAt: Date.now(), messages: tail }
845
+ : { bivyKind: "base", reset: false, createdAt: Date.now(), messages: tail, ...(shared < prevKeys.length ? { keep: shared } : {}) };
785
846
  this.baseKeys.set(id, nextKeys);
786
847
  this.enqueue(id, this.syntheticKey(id), record);
787
848
  }
@@ -832,10 +893,6 @@ export class EventLog {
832
893
  this.load(id);
833
894
  this.enqueue(id, `pin:${entry.pin.id}`, { bivyKind: "app-pin", createdAt: entry.createdAt ?? Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.pin.id, pin: structuredClone(entry.pin) });
834
895
  }
835
- appendSuggestion(id, entry) {
836
- this.load(id);
837
- this.enqueue(id, `suggestion:${entry.suggestion.id}`, { bivyKind: "suggestion", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.suggestion.id, suggestion: { ...entry.suggestion } });
838
- }
839
896
  appendNotice(id, entry) {
840
897
  this.load(id);
841
898
  this.enqueue(id, `notice:${entry.notice.id}`, { bivyKind: "notice", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.notice.id, notice: { ...entry.notice } });
@@ -10,27 +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, the same convention EPHEMERAL_ALLOWED_HOSTS uses in
26
- * src/ephemeral-exec.ts for its cross-copy host allowlist. If you change one,
27
- * change the other.
28
- */
29
- const INLINE_IMAGE_MD_RE = /!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g;
30
- /** Bound how many distinct remote images a single message can trigger a fetch
31
- * for — a pathological/malicious message can't fan out into an unbounded
32
- * number of outbound requests. */
33
- export const MAX_INLINE_IMAGES_PER_MESSAGE = 6;
22
+ import { extractImageReferences, extractMessageReferences, 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;
34
28
  /** Ceiling for a single fetched inline image. Smaller than
35
29
  * MAX_AGENT_ATTACHMENT_BYTES (attach-to-chat.ts) because these bytes come from
36
30
  * an arbitrary, untrusted remote origin rather than the local workspace —
@@ -40,23 +34,20 @@ export const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
40
34
  const FETCH_TIMEOUT_MS = 10_000;
41
35
  /** Hard cap on redirect hops, mirroring execEphemeralRequest's guard. */
42
36
  const MAX_REDIRECTS = 5;
43
- /** Extract the distinct `https://` URLs a message's raw markdown references via
44
- * `![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. */
45
41
  export function extractInlineImageUrls(text) {
46
- if (!text)
47
- return [];
48
- const out = [];
49
- const seen = new Set();
50
- for (const match of text.matchAll(INLINE_IMAGE_MD_RE)) {
51
- const url = match[1];
52
- if (!url || seen.has(url))
53
- continue;
54
- seen.add(url);
55
- out.push(url);
56
- if (out.length >= MAX_INLINE_IMAGES_PER_MESSAGE)
57
- break;
58
- }
59
- return out;
42
+ return extractImageReferences(text).filter((i) => i.origin === "remote").map((i) => i.ref);
43
+ }
44
+ /** The distinct workspace references a message makes — from image syntax or a
45
+ * `::view{src=…}` directive — in first-seen order, under the same per-message
46
+ * cap. `syntax` tells the resolver whether non-image bytes are acceptable. */
47
+ export function extractWorkspaceRefs(text) {
48
+ return extractMessageReferences(text)
49
+ .filter((r) => r.origin === "workspace")
50
+ .map(({ ref, syntax }) => ({ ref, syntax }));
60
51
  }
61
52
  /** Best-effort plain text for an assistant RuntimeMessage's `content`, which is
62
53
  * either a plain string or an array of typed blocks (`{type:"text", text}`
@@ -0,0 +1,323 @@
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
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Components the agent places with a directive or a spec
157
+ //
158
+ // `![alt](path)` says "show this image here" and nothing else. The two forms
159
+ // below are how an agent says anything more — show this FILE, show this CSV as a
160
+ // table — without a second out-of-band channel per kind:
161
+ //
162
+ // ::view{src=data/sales.csv} point at something in the workspace
163
+ // ```bivy … ``` carry a small spec inline
164
+ //
165
+ // Both land as the same value, a ComponentPlacement, and both render through one
166
+ // table keyed by `kind`. Adding a kind is a row in that table, not another
167
+ // branch here. Anything this file cannot make sense of stays the text the agent
168
+ // wrote, because an agent WILL get the syntax wrong and a message must still
169
+ // read.
170
+ // ---------------------------------------------------------------------------
171
+ /** The info string that marks a fenced block as a component spec. */
172
+ export const COMPONENT_FENCE_LANG = "bivy";
173
+ /** Ceiling for one inline spec. A spec rides in the message and then in every
174
+ * rendered copy of it, so it is for a handful of values — a file is the right
175
+ * home for real data. Past this the fence stays an ordinary code block. */
176
+ export const MAX_COMPONENT_SPEC_CHARS = 16 * 1024;
177
+ /** Ceiling for a directive's attribute list, bounding the parse below. */
178
+ const MAX_ATTRS_CHARS = 512;
179
+ /** Leaf directives this grammar knows. `view` is the general one; a named leaf
180
+ * can be added later without touching the parser. */
181
+ const LEAF_NAMES = new Set(["view"]);
182
+ /**
183
+ * `{src=a/b.csv height=200 caption="Last quarter" #fig-1 .wide}` →
184
+ * `{ src: "a/b.csv", height: "200", caption: "Last quarter", id: "fig-1", class: "wide" }`
185
+ *
186
+ * Every repetition is bounded (see imagePattern's note — this runs on the same
187
+ * agent-authored text), and the whole list is length-capped, so a pathological
188
+ * attribute string is refused rather than parsed slowly.
189
+ */
190
+ export function parseAttrs(source) {
191
+ const out = {};
192
+ const text = String(source || "").trim().replace(/^\{|\}$/g, "");
193
+ if (!text || text.length > MAX_ATTRS_CHARS)
194
+ return out;
195
+ const re = /([#.]?[\w-]{1,64})(?:\s*=\s*(?:"([^"\n]{0,512})"|'([^'\n]{0,512})'|([^\s"'}]{1,512})))?/g;
196
+ for (const m of text.matchAll(re)) {
197
+ const key = m[1] ?? "";
198
+ const value = m[2] ?? m[3] ?? m[4];
199
+ if (key.startsWith("#"))
200
+ out.id = key.slice(1);
201
+ else if (key.startsWith("."))
202
+ out.class = out.class ? `${String(out.class)} ${key.slice(1)}` : key.slice(1);
203
+ else if (key)
204
+ out[key] = value === undefined ? true : value;
205
+ }
206
+ return out;
207
+ }
208
+ /**
209
+ * A whole line that is a leaf directive, or null. Block-level by construction:
210
+ * the directive must own its line, so a `::view{…}` written mid-sentence is
211
+ * prose and a half-streamed one cannot render until its line is complete.
212
+ *
213
+ * `src` is validated as a WORKSPACE reference. A directive is not a link — it
214
+ * names something Bivy has to read — so a remote URL is refused here even
215
+ * though `![](https://…)` accepts one.
216
+ */
217
+ export function parseComponentDirective(line) {
218
+ // Surrounding whitespace is trimmed rather than matched, leaving exactly ONE
219
+ // whitespace quantifier in the pattern. Two of them around an optional group
220
+ // (`::name[ \t]*{…}?[ \t]*$`) is ambiguous — a run of tabs can be split
221
+ // between them in as many ways as it is long, which is quadratic on a line
222
+ // like `::a` followed by whitespace, and this runs on agent-authored text.
223
+ // See imagePattern's note; the same rule applies to every pattern here.
224
+ const m = /^::([a-z][\w-]{0,31})[ \t]{0,32}(\{[^}\n]{0,512}\})?$/i.exec(String(line || "").trim());
225
+ if (!m || !LEAF_NAMES.has((m[1] ?? "").toLowerCase()))
226
+ return null;
227
+ const { src, ...attrs } = parseAttrs(m[2] ?? "");
228
+ const ref = typeof src === "string" ? unescapeComponentRef(src) : "";
229
+ if (!ref || classifyImageTarget(ref) !== "workspace")
230
+ return null;
231
+ return { ref, spec: null, attrs };
232
+ }
233
+ /** True for the info string of a component fence (```bivy). */
234
+ export function isComponentFence(info) {
235
+ return String(info || "").trim().toLowerCase() === COMPONENT_FENCE_LANG;
236
+ }
237
+ /**
238
+ * A component fence's body as a spec object, or null to leave the fence as an
239
+ * ordinary code block — which is what an agent gets for malformed JSON, and is
240
+ * strictly better than an error: the source stays readable and copyable.
241
+ */
242
+ export function parseComponentSpec(body) {
243
+ const text = String(body || "").trim();
244
+ if (!text || text.length > MAX_COMPONENT_SPEC_CHARS)
245
+ return null;
246
+ let parsed;
247
+ try {
248
+ parsed = JSON.parse(text);
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
254
+ return null;
255
+ return parsed;
256
+ }
257
+ /**
258
+ * How a component is rendered, from its spec's own `type` or from the file it
259
+ * points at. One table, so a new kind is a row here plus a row in the view
260
+ * layer's registry — never a branch in the parser.
261
+ *
262
+ * Returns a kind string even for a kind this build cannot render; the registry
263
+ * decides, and falls back visibly rather than dropping the component.
264
+ */
265
+ export function componentKind(opts) {
266
+ const declared = opts.spec?.type;
267
+ if (typeof declared === "string" && declared.trim())
268
+ return declared.trim().toLowerCase();
269
+ if (opts.spec)
270
+ return "unknown";
271
+ const mime = String(opts.mimeType || "").toLowerCase();
272
+ if (mime.startsWith("image/"))
273
+ return "image";
274
+ // Everything else is a file the reader can open or download. Rows for richer
275
+ // readings of a file — a CSV as a table, a notebook, a diff — belong here
276
+ // alongside the renderer that can actually draw them. A row added early would
277
+ // promise a kind the view layer has to decline, turning a useful download into
278
+ // "this version cannot show that yet".
279
+ return "file";
280
+ }
281
+ /**
282
+ * Every reference a message makes that Bivy has to resolve — from image syntax
283
+ * or from a directive — de-duplicated, in first-seen order, under ONE shared
284
+ * cap. One budget across both forms is deliberate: the cost being bounded is
285
+ * file reads and persisted bytes, which do not care which syntax asked.
286
+ */
287
+ export function extractMessageReferences(text, limit = MAX_COMPONENTS_PER_MESSAGE) {
288
+ if (!text)
289
+ return [];
290
+ const source = String(text);
291
+ const found = [];
292
+ // Images are matched against the WHOLE text, not line by line: alt text may
293
+ // contain a newline (an agent wrapping a long caption at 80 columns), and the
294
+ // renderer — which sees a paragraph with its lines already joined — would
295
+ // place a mount point the node then never resolved.
296
+ for (const match of source.matchAll(imagePattern())) {
297
+ const ref = unescapeComponentRef(match[2] ?? "");
298
+ const origin = classifyImageTarget(ref);
299
+ if (origin)
300
+ found.push({ ref, origin, syntax: "image", at: match.index ?? 0 });
301
+ }
302
+ // Directives own a whole line, so they are matched per line; the running
303
+ // offset keeps them in document order with the images above.
304
+ let at = 0;
305
+ for (const line of source.split("\n")) {
306
+ const directive = parseComponentDirective(line);
307
+ if (directive?.ref)
308
+ found.push({ ref: directive.ref, origin: "workspace", syntax: "view", at });
309
+ at += line.length + 1;
310
+ }
311
+ found.sort((a, b) => a.at - b.at);
312
+ const out = [];
313
+ const seen = new Set();
314
+ for (const { ref, origin, syntax } of found) {
315
+ if (seen.has(ref))
316
+ continue;
317
+ seen.add(ref);
318
+ out.push({ ref, origin, syntax });
319
+ if (out.length >= limit)
320
+ break;
321
+ }
322
+ return out;
323
+ }