@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.
- package/README.md +6 -5
- package/bin/bivy.mjs +13 -56
- package/bin/cli-commands.mjs +2 -4
- package/bin/guides/talk-to-the-user.md +13 -24
- package/dist/agent-instructions.js +12 -14
- package/dist/agents/pi/integration.js +1 -1
- package/dist/apps/remote-preview.js +3 -0
- package/dist/apps/service.js +6 -1
- package/dist/apps/styles.css +40 -102
- package/dist/automation-reseal.js +30 -0
- package/dist/certification/generated.js +1 -1
- package/dist/device-registry.js +27 -0
- package/dist/ephemeral-teardown.js +11 -1
- package/dist/exec.js +4 -1
- package/dist/runtime/model-catalog.js +16 -0
- package/dist/server.js +73 -62
- package/dist/session/event-log.js +98 -41
- package/dist/session/inline-image-fetch.js +24 -33
- package/dist/session/message-components.js +323 -0
- package/dist/session/run-terminal.js +9 -7
- package/dist/session/session-tokens.js +1 -1
- package/dist/session/transcript-persistence.js +106 -11
- package/package.json +6 -6
- package/web/assets/AutomationsView-Cobo4Kep.js +51 -0
- package/web/assets/{ChangesCard-DcSRy5g_.js → ChangesCard-Bo6E8C_y.js} +3 -3
- package/web/assets/LibraryView-BTeMc6BO.js +2 -0
- package/web/assets/{ProviderConnect-cpGwBMrU.js → ProviderConnect-CsidS7kq.js} +1 -1
- package/web/assets/SessionList-B7IbeXSa.js +1 -0
- package/web/assets/Settings-BQRQsQ36.js +10 -0
- package/web/assets/{Sheet-D-5t3lJ0.js → Sheet-C0BIPbMr.js} +1 -1
- package/web/assets/{Terminal-CjMjKB1f.js → Terminal-BTRgonB5.js} +1 -1
- package/web/assets/{VoiceSettings-xv6rjaY2.js → VoiceSettings-UZ1SA0lQ.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-BLT3MLyK.js → abnfDiagram-VCTEODGH-CJoWsvVk.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-CuzcodTO.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BGinLETW.js → architectureDiagram-5GKGNRK7-_qpa4O1v.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-BNiY2HLB.js → blockDiagram-I7D4REHJ-DmLgwSr-.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-46aGfRMd.js → c4Diagram-7LVT6UL2-DeZuOOSk.js} +1 -1
- package/web/assets/channel-B0OL-aPd.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-CRzer-vm.js → chunk-4HAMMTFA-Cr6O6lBG.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-MFkgf2Ar.js → chunk-75Z2AOVW-gSgNIYMY.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-Bpze6ID8.js → chunk-DU6HZSFF-BacnRf2Y.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-Dq3OmNXG.js → chunk-F27PBJKO-DRrpfF1z.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-umDWdG9g.js → chunk-GMAD6QVW-DXCi6v6B.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-DuT1Awt9.js → chunk-GVQU2GXP-Yd74hIeT.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-CpFFccVb.js → chunk-IMKFNOWR-D5xz0-QC.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-0jRWrASa.js → chunk-L3NEJ4N5-BrS0CGPM.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-CBErW4-p.js → chunk-OSK3NFVY-C6d1DSJe.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-BPMii7LM.js → chunk-P2QGCYS3-BrKgIngm.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-CHTRMb_C.js → chunk-POPQ4Y6H-Bkwx5BZs.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-BOcNKrmu.js → chunk-PWAF6VOD-AeCCeE0F.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-Dvb-oEDN.js → chunk-SHT3W25Y-01NYjozy.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-B4AQIUTK.js → chunk-SVP7TREG-CCh7wXAI.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-CWzLTsHW.js → chunk-TICWLB2K-CcWKqwdM.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-D8dSdFQt.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-D8dSdFQt.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-BPkKT48j.js → cynefin-OW5HDTMX-CYYc_tBE.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-Ck2qLmpR.js → cynefinDiagram-5FMLGOSQ-Dnz7OfxK.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-BPKEs_49.js → dagre-GXQ25YYZ-di5CyaMt.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-ou-L5HdJ.js → diagram-S7CK7UJ4-BVC93OLB.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-DihVn6fF.js → diagram-UQ7AKVKN-D7pXHAvQ.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-BkLtxbP7.js → diagram-VSXAHHWV-d0zW-IUz.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-BZ-Z-CwP.js → diagram-VX7I27RA-BRlMo9oa.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-BQN-bGqD.js → diagram-Z3DM3KII-BmfntB1i.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-aKK1a_it.js → ebnfDiagram-PWID7BFC-BxUYvqsz.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-BGsXtJl-.js → erDiagram-RLTQ6QDP-CbUl1Cqa.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-sTVSUyNU.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-Beo3zBEU.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-lrYuyNAe.js → ganttDiagram-EL5Y4UJY-CkpR0avQ.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-raj_IxZ8.js → gitGraph-4MIJSDKK-CmbJxSaM.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-DzqRGXuk.js → gitGraphDiagram-WWUBYQGX-iyO408KF.js} +1 -1
- package/web/assets/index-B4HaCD-n.css +1 -0
- package/web/assets/{index-B6GPr2Pt.js → index-fdEPLcPs.js} +2 -2
- package/web/assets/{info-A6RAGUB7-DJVi9SAN.js → info-A6RAGUB7--6JCM0xX.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-B2HrGywP.js → infoDiagram-27XIBGKW-Dchx6QcB.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-CczIkvWw.js → ishikawaDiagram-5VMMS53U-DOY3izUi.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D-h-kwep.js → journeyDiagram-3NMN7TZE-oqZgC9oJ.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-GClrFWfH.js → kanban-definition-UXKFOSKX-HCRC0D9X.js} +1 -1
- package/web/assets/{line-pweWV0oc.js → line-qcY2eok7.js} +1 -1
- package/web/assets/{mermaid-parser.core-C1vHZCtC.js → mermaid-parser.core-Bullp1xl.js} +3 -3
- package/web/assets/{mermaid.core-Df8DJ7AE.js → mermaid.core-FBmsAtMC.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-BpqA307n.js → mindmap-definition-YA3MSWOX-CfgOAkVQ.js} +1 -1
- package/web/assets/mount-IY4-2elJ.js +31 -0
- package/web/assets/{packet-AYTQ26CC-afE6T0Hc.js → packet-AYTQ26CC-COAmiopo.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-AAEw7Khy.js → pegDiagram-XKGWAZYB-BYafVbeG.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-vtwSUVVK.js → pie-WAS4IAKB-ZYe346n6.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-CDg0SkhS.js → pieDiagram-E7YTZNPT-DnRbaA7i.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-_o0rOR26.js → quadrantDiagram-AXDQQJYC-B2uxyKLd.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-_Tf7tpz8.js → radar-RG4KPBEZ-DwFlqYgB.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-DM_a0HtL.js → railroad-74A4TZTK-DBv5jh9o.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-VQxaaUVU.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-DWKO1s90.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-DvViPquF.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-BpQZeQ-f.js → railroadDiagram-O6MQD6OU-CIUFwDWd.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-DC4US4K_.js → requirementDiagram-BXWQKSXE-BXwDlf9Q.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-BUJ9KIqV.js → sankeyDiagram-P5KCCOFB-DTiRcy5E.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-By7D5XFY.js → sequenceDiagram-WJ2MYXX4-DFPRDr4a.js} +1 -1
- package/web/assets/settingsRoute-3489vHS0.js +1 -0
- package/web/assets/{stateDiagram-D77RDMKH-D0BA3EeM.js → stateDiagram-D77RDMKH-CFQ-4xlY.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DIIQkE2r.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-BNUI3ghm.js → swimlanes-42K2YHIH-Bke6VXs0.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Drds9YUy.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-DVzeoz4F.js → timeline-definition-24CTP7MA-Cs2-dpWN.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-CnvdZw-U.js → treeView-Q6P3EWNA-Dr8vsD3U.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-Ax_RXzPk.js → treemap-WGGIJYW6-DbvC3nsA.js} +1 -1
- package/web/assets/useStore-BDEjghiw.js +23 -0
- package/web/assets/{vennDiagram-4TSXK5OY-CNauH8S4.js → vennDiagram-4TSXK5OY-CdUoLWPo.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-q8WnmSMu.js → wardley-WFR3VGLG-BL7j6fb-.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-DKOY4P11.js → wardleyDiagram-VM6X3IG4-BEVYmHWI.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-m8KaPfml.js → xychartDiagram-S5SC5T6Z-C9zn_1JB.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/dist/ephemeral-exec.js +0 -108
- package/dist/session/suggestions.js +0 -17
- package/web/assets/AutomationsView-DEild20v.js +0 -51
- package/web/assets/LibraryView-Cu0tU0gU.js +0 -2
- package/web/assets/Settings-BoTU83-i.js +0 -10
- package/web/assets/architecture-7GRP2DOG-s28sMQ0-.js +0 -1
- package/web/assets/channel-CPSM7rj7.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-CZ-So67n.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-CZ-So67n.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-Dd2mk6iz.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-D7f1jYIL.js +0 -1
- package/web/assets/index-D76QD5dl.css +0 -1
- package/web/assets/mount-ISRAcaLu.js +0 -33
- package/web/assets/railroad-abnf-HS5TGJTU-BzFZIgti.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-BkKCtTE4.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-CuXWl9dG.js +0 -1
- package/web/assets/settingsRoute-DSj-6Pdo.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-2_bDqGCB.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-SmXx3nAj.js +0 -8
- 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 `
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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) ||
|
|
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, ...
|
|
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(
|
|
563
|
-
|
|
555
|
+
function parseLogDetailed(lines, keep = () => true) {
|
|
556
|
+
let records = [];
|
|
564
557
|
let malformedLines = 0;
|
|
565
|
-
|
|
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
|
|
670
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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 —
|
|
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
|
-
* 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
|
-
/**
|
|
44
|
-
* ``, 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. */
|
|
45
41
|
export function extractInlineImageUrls(text) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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&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
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Components the agent places with a directive or a spec
|
|
157
|
+
//
|
|
158
|
+
// `` 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 `` 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
|
+
}
|