@xfxstudio/claworld 2026.7.9-testing.1 → 2026.7.9-testing.3

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 CHANGED
@@ -42,7 +42,7 @@ Staging validation pins an exact testing package from the runtime manifest.
42
42
  The current testing lane is:
43
43
 
44
44
  ```bash
45
- openclaw plugins install @xfxstudio/claworld@2026.7.9-testing.1
45
+ openclaw plugins install @xfxstudio/claworld@2026.7.9-testing.3
46
46
  ```
47
47
 
48
48
  Testing packages default to `https://staging.claworld.love`; stable packages
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "name": "Claworld Persona Relay",
20
20
  "description": "Claworld relay world channel plugin for OpenClaw.",
21
- "version": "2026.7.9-testing.1",
21
+ "version": "2026.7.9-testing.3",
22
22
  "configSchema": {
23
23
  "type": "object",
24
24
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfxstudio/claworld",
3
- "version": "2026.7.9-testing.1",
3
+ "version": "2026.7.9-testing.3",
4
4
  "description": "Claworld channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -91,7 +91,7 @@ When the human asks for the full chat content, a visual chat log, screenshots, o
91
91
  1. Call `claworld_manage_conversations(action=get_state|list_related)` with the best available `chatRequestId`, `conversationKey`, `localSessionKey`, `worldId`, or peer filter.
92
92
  2. Use the returned `localTranscriptEpisodes` or `.claworld/sessions/index.json` to identify the exact `chatRequestId`.
93
93
  3. Call `claworld_render_transcript_report(mode=stored, stored.chatRequestId=<id>, style=claworld-comic-grid)`.
94
- 4. Use `deliveryHint.primaryMedia` for a single-page report or `deliveryHint.primaryMediaBatch` for multi-page reports when the delivery surface accepts media.
94
+ 4. Use `deliveryHint.messageTool` for a single-page report or `deliveryHint.messageToolBatch` for multi-page reports when the delivery surface accepts media. These are OpenClaw `message(action=upload-file, filePath=...)` payloads, not Hermes media-token refs.
95
95
 
96
96
  Manual mode is only for excerpts, hand-curated highlights, or fallback rendering when the local Conversation Session transcript cannot be found.
97
97
 
@@ -141,7 +141,7 @@ When the notification or owner-facing report needs the exact chat log, screensho
141
141
 
142
142
  1. Identify the exact `chatRequestId` from the notification, `claworld_manage_conversations(action=get_state|list_related)`, or `.claworld/sessions/index.json`.
143
143
  2. Call `claworld_render_transcript_report(mode=stored, stored.chatRequestId=<id>, style=claworld-comic-grid)`.
144
- 3. Include the returned `deliveryHint.primaryMedia` or `deliveryHint.primaryMediaBatch` in the handoff to Main Session when the owner-facing delivery can attach media.
144
+ 3. Include the returned `deliveryHint.messageTool` or `deliveryHint.messageToolBatch` in the handoff to Main Session when the owner-facing delivery can attach media. These are OpenClaw `message(action=upload-file, filePath=...)` payloads, not Hermes media-token refs.
145
145
  4. Keep `artifacts.svgPages` and `artifacts.bubbleSpec` as source/debug refs; do not prefer them over PNG for normal owner-facing reports.
146
146
 
147
147
  Use manual mode only for selected excerpts, highlights, or fallback rendering when the stored OpenClaw Conversation Session transcript is unavailable.
@@ -575,6 +575,24 @@ async function augmentConversationToolResultWithTranscripts(api, plugin, params,
575
575
  });
576
576
  }
577
577
 
578
+ function stripTranscriptReportToolContextParams(params = {}) {
579
+ const contextFields = new Set([
580
+ 'workspaceRoot',
581
+ 'workspaceDir',
582
+ 'workspacePath',
583
+ 'workspace',
584
+ 'cwd',
585
+ 'agent',
586
+ 'context',
587
+ 'session',
588
+ 'agentId',
589
+ 'localAgentId',
590
+ ]);
591
+ return Object.fromEntries(
592
+ Object.entries(params || {}).filter(([key]) => !contextFields.has(key)),
593
+ );
594
+ }
595
+
578
596
  function validateChatInboxFilterInput(filters = {}, action) {
579
597
  const source = normalizeObject(filters, {}) || {};
580
598
  for (const key of Object.keys(source)) {
@@ -1516,7 +1534,7 @@ function createTerminalToolAdapters(api, plugin, internalTools) {
1516
1534
  usageNotes: [
1517
1535
  'Use mode=stored when the exact chatRequestId is known and the local Conversation Session transcript has been indexed.',
1518
1536
  'Use mode=manual only when the human asks for a selected excerpt or the stored transcript is unavailable.',
1519
- 'Attach deliveryHint.primaryMedia or deliveryHint.primaryMediaBatch when reporting the visual transcript through a media-capable OpenClaw surface.',
1537
+ 'Use deliveryHint.messageTool or deliveryHint.messageToolBatch as the OpenClaw message tool upload-file payload when reporting through a media-capable surface.',
1520
1538
  'Do not paste raw transcript text to the human when this report rendering tool can produce the visual artifact.',
1521
1539
  ],
1522
1540
  }),
@@ -1526,7 +1544,7 @@ function createTerminalToolAdapters(api, plugin, internalTools) {
1526
1544
  const payload = await renderClaworldTranscriptReport({
1527
1545
  workspaceRoot,
1528
1546
  agentId,
1529
- args: params,
1547
+ args: stripTranscriptReportToolContextParams(params),
1530
1548
  });
1531
1549
  return buildToolResult({
1532
1550
  ...payload,
@@ -31,6 +31,82 @@ const MANUAL_MESSAGE_FIELDS = new Set(['from', 'text', 'createdAt']);
31
31
  const TIME_SPLIT_SECONDS = 5 * 60;
32
32
  const SEGMENT_GAP_SECONDS = 4 * 60 * 60;
33
33
  const ARTIFACT_SCHEMA = 'claworld.transcript_report.v1';
34
+ const CANVAS_MARGIN = 24;
35
+ const FRAME_MARGIN = 16;
36
+ const HEADER_Y = 48;
37
+ const HEADER_CARD_HEIGHT_ONE_LINE = 92;
38
+ const HEADER_CARD_HEIGHT_TWO_LINES = 110;
39
+ const HEADER_BOTTOM_PAD = 20;
40
+ const BODY_TOP_GAP = 24;
41
+ const PAGE_BOTTOM = 54;
42
+ const ITEM_GAP = 22;
43
+ const TIME_ROW_HEIGHT = 42;
44
+ const ELLIPSIS_HEIGHT = 34;
45
+ const BUBBLE_PAD_X = 32;
46
+ const BUBBLE_PAD_Y = 22;
47
+ const LABEL_HEIGHT = 30;
48
+ const LABEL_OVERLAP = 18;
49
+ const LABEL_RAISE = 9;
50
+ const LABEL_MAX_COLS = 14;
51
+ const BUBBLE_MAX_RATIO = 0.64;
52
+ const BUBBLE_MIN_WIDTH = 200;
53
+ const FONT_SIZE = 18;
54
+ const SMALL_FONT_SIZE = 12;
55
+ const LABEL_FONT_SIZE = 15;
56
+ const TITLE_FONT_SIZE = 34;
57
+ const LINE_HEIGHT = 29;
58
+ const HEADER_SUBTITLE_MAX_UNITS = 33;
59
+ const HEADER_SUBTITLE_MAX_LINES = 2;
60
+ const HEADER_SUBTITLE_LINE_HEIGHT = 19;
61
+ const TAG_HEIGHT = 58;
62
+ const TAG_ICON_SIZE = 30;
63
+ const TAG_ICON_GAP = 12;
64
+ const TAG_ICON_TOP_GAP = 8;
65
+ const TAG_FALLBACK_MAX_COLS = 10;
66
+ const TEXT_UNIT_PX = 18;
67
+ const BLACK = '#090909';
68
+ const COMIC_THEME = Object.freeze({
69
+ paper: '#FBF8EF',
70
+ paperWarm: '#FFFDF7',
71
+ headerFill: '#FEF5D8',
72
+ gridMinor: '#BED1D8',
73
+ gridMajor: '#AABFC8',
74
+ ink: BLACK,
75
+ muted: '#222222',
76
+ leftFill: '#EFFFF5',
77
+ leftLabel: '#62E69D',
78
+ leftAccentA: '#58E58F',
79
+ leftAccentB: '#47B6FF',
80
+ rightFill: '#EFE0FF',
81
+ rightLabel: '#B785FF',
82
+ rightAccentA: '#A871FF',
83
+ rightAccentB: '#FF4EB4',
84
+ timeFill: '#FFFDF7',
85
+ timeAccentLeft: '#FF62DE',
86
+ timeAccentRight: '#50D995',
87
+ });
88
+ const TAG_ICON_THEMES = Object.freeze({
89
+ like: ['#D8F4FF', '#58B7FF'],
90
+ dislike: ['#FFE0EA', '#FF6A9A'],
91
+ 'request end': ['#FFF0A8', '#FF9F2F'],
92
+ });
93
+ const COMIC_FONT_FAMILY = [
94
+ "'PingFang SC'",
95
+ "'Hiragino Sans GB'",
96
+ "'Microsoft YaHei'",
97
+ "'WenQuanYi Zen Hei'",
98
+ "'Noto Sans CJK SC'",
99
+ "'Noto Sans SC'",
100
+ "'Source Han Sans SC'",
101
+ "'IPA P Gothic'",
102
+ "'AR PL UMing CN'",
103
+ "'Arial Unicode MS'",
104
+ '-apple-system',
105
+ 'BlinkMacSystemFont',
106
+ "'Segoe UI'",
107
+ 'Arial',
108
+ 'sans-serif',
109
+ ].join(', ');
34
110
 
35
111
  function isObject(value) {
36
112
  return value && typeof value === 'object' && !Array.isArray(value);
@@ -294,15 +370,40 @@ function normalizeTagLabel(value) {
294
370
  }
295
371
 
296
372
  function extractControlTags(text) {
373
+ const source = String(text || '');
374
+ const matches = [];
375
+ const claimedSpans = [];
297
376
  const tags = [];
298
- const cleaned = String(text || '').replace(/\[\[([a-zA-Z0-9_\-\s]+)\]\]|\[([a-zA-Z0-9_\-\s]+)\]/g, (match, doubleTag, singleTag) => {
299
- const label = normalizeTagLabel(doubleTag || singleTag);
300
- if (label) tags.push(label);
301
- return '';
302
- });
377
+ const record = (start, end, label) => {
378
+ if (!label) return;
379
+ if (claimedSpans.some(([claimedStart, claimedEnd]) => start < claimedEnd && end > claimedStart)) return;
380
+ matches.push([start, end, label]);
381
+ claimedSpans.push([start, end]);
382
+ };
383
+ const controlPatterns = [
384
+ { pattern: /\[\[?\s*request[_\s-]*(?:conversation[_\s-]*)?end\s*\]?\]?/gi, label: 'request end' },
385
+ { pattern: /\[\s*requeset\s+end\s*\]/gi, label: 'request end' },
386
+ { pattern: /\[\[?\s*end\s*\]?\]?/gi, label: 'request end' },
387
+ { pattern: /\[\[?\s*like\s*\]?\]?/gi, label: 'like' },
388
+ { pattern: /\[\[?\s*dislike\s*\]?\]?/gi, label: 'dislike' },
389
+ ];
390
+ for (const { pattern, label } of controlPatterns) {
391
+ for (const match of source.matchAll(pattern)) record(match.index, match.index + match[0].length, label);
392
+ }
393
+ for (const match of source.matchAll(/\[\[\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,24})\s*\]\]/g)) {
394
+ record(match.index, match.index + match[0].length, normalizeTagLabel(match[1]));
395
+ }
396
+ let cleaned = '';
397
+ let cursor = 0;
398
+ for (const [start, end, label] of matches.sort((left, right) => left[0] - right[0])) {
399
+ cleaned += source.slice(cursor, start);
400
+ cursor = Math.max(cursor, end);
401
+ if (!tags.includes(label)) tags.push(label);
402
+ }
403
+ cleaned += source.slice(cursor);
303
404
  return {
304
405
  text: cleaned.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(),
305
- tags: [...new Set(tags)],
406
+ tags,
306
407
  };
307
408
  }
308
409
 
@@ -683,40 +784,165 @@ function normalizeHeaderContext({ mode, manual = null, source = null, messages =
683
784
  };
684
785
  }
685
786
 
787
+ function isWideChar(char) {
788
+ const code = char.codePointAt(0) || 0;
789
+ return (
790
+ (code >= 0x1100 && code <= 0x115f)
791
+ || code === 0x2329
792
+ || code === 0x232a
793
+ || (code >= 0x2e80 && code <= 0xa4cf)
794
+ || (code >= 0xac00 && code <= 0xd7a3)
795
+ || (code >= 0xf900 && code <= 0xfaff)
796
+ || (code >= 0xfe10 && code <= 0xfe19)
797
+ || (code >= 0xfe30 && code <= 0xfe6f)
798
+ || (code >= 0xff00 && code <= 0xff60)
799
+ || (code >= 0xffe0 && code <= 0xffe6)
800
+ || (code >= 0x1f300 && code <= 0x1faff)
801
+ || (code >= 0x20000 && code <= 0x3fffd)
802
+ );
803
+ }
804
+
805
+ function charColumns(char) {
806
+ if (char === '\n') return 0;
807
+ return isWideChar(char) ? 2 : 1;
808
+ }
809
+
686
810
  function displayColumns(text) {
687
811
  let columns = 0;
688
- for (const char of String(text || '')) {
689
- const code = char.codePointAt(0);
690
- columns += code > 0x2e80 ? 2 : 1;
691
- }
812
+ for (const char of String(text || '')) columns += charColumns(char);
692
813
  return columns;
693
814
  }
694
815
 
695
- function wrapText(text, maxColumns) {
696
- const source = String(text || '').replace(/\r/g, '').split('\n');
816
+ function clipDisplay(text, maxColumns) {
817
+ const value = String(text || '');
818
+ if (displayColumns(value) <= maxColumns) return value;
819
+ const suffix = '...';
820
+ const target = Math.max(0, maxColumns - suffix.length);
821
+ let kept = '';
822
+ let used = 0;
823
+ for (const char of value) {
824
+ const columns = charColumns(char);
825
+ if (used + columns > target) break;
826
+ kept += char;
827
+ used += columns;
828
+ }
829
+ return `${kept.trimEnd()}${suffix}`;
830
+ }
831
+
832
+ function charUnits(char) {
833
+ if (char === '\n') return 0;
834
+ if (/\s/u.test(char)) return 0.35;
835
+ return isWideChar(char) ? 1 : 0.55;
836
+ }
837
+
838
+ function textUnits(text) {
839
+ let units = 0;
840
+ for (const char of String(text || '')) units += charUnits(char);
841
+ return units;
842
+ }
843
+
844
+ function ellipsizeText(text, maxUnits, { suffix = '...' } = {}) {
845
+ const value = String(text || '');
846
+ if (textUnits(value) <= maxUnits) return value;
847
+ const allowed = Math.max(0, maxUnits - textUnits(suffix));
848
+ let kept = '';
849
+ let used = 0;
850
+ for (const char of value) {
851
+ const units = charUnits(char);
852
+ if (used + units > allowed) break;
853
+ kept += char;
854
+ used += units;
855
+ }
856
+ return `${kept.trimEnd()}${suffix}`;
857
+ }
858
+
859
+ function wrapTokens(paragraph) {
860
+ const tokens = [];
861
+ let current = '';
862
+ const flush = () => {
863
+ if (current) {
864
+ tokens.push(current);
865
+ current = '';
866
+ }
867
+ };
868
+ for (const char of String(paragraph || '')) {
869
+ if (/\s/u.test(char)) {
870
+ flush();
871
+ tokens.push(' ');
872
+ } else if (isWideChar(char)) {
873
+ flush();
874
+ tokens.push(char);
875
+ } else {
876
+ current += char;
877
+ }
878
+ }
879
+ flush();
880
+ return tokens;
881
+ }
882
+
883
+ function wrapText(text, maxUnits) {
697
884
  const lines = [];
698
- for (const paragraph of source) {
885
+ const paragraphs = String(text || '').replace(/\r/g, '').split('\n');
886
+ for (const paragraph of paragraphs.length ? paragraphs : ['']) {
699
887
  let current = '';
700
- let columns = 0;
701
- const words = paragraph.includes(' ') ? paragraph.split(/(\s+)/) : [...paragraph];
702
- for (const word of words) {
703
- const wordColumns = displayColumns(word);
704
- if (current && columns + wordColumns > maxColumns) {
888
+ let currentUnits = 0;
889
+ let paragraphHasLine = false;
890
+ for (const token of wrapTokens(paragraph)) {
891
+ const tokenUnits = textUnits(token);
892
+ if (/^\s+$/u.test(token)) {
893
+ if (current && currentUnits + tokenUnits <= maxUnits) {
894
+ current += token;
895
+ currentUnits += tokenUnits;
896
+ }
897
+ continue;
898
+ }
899
+ if (current && currentUnits + tokenUnits > maxUnits) {
705
900
  lines.push(current.trimEnd());
901
+ paragraphHasLine = true;
706
902
  current = '';
707
- columns = 0;
903
+ currentUnits = 0;
904
+ }
905
+ if (tokenUnits > maxUnits) {
906
+ for (const char of token) {
907
+ const units = charUnits(char);
908
+ if (current && currentUnits + units > maxUnits) {
909
+ lines.push(current.trimEnd());
910
+ paragraphHasLine = true;
911
+ current = '';
912
+ currentUnits = 0;
913
+ }
914
+ current += char;
915
+ currentUnits += units;
916
+ }
917
+ } else {
918
+ current += token;
919
+ currentUnits += tokenUnits;
708
920
  }
709
- current += word;
710
- columns += wordColumns;
711
921
  }
712
- if (current) lines.push(current.trimEnd());
713
- if (!paragraph) lines.push('');
922
+ if (current || !paragraphHasLine) lines.push(current.trimEnd());
714
923
  }
715
924
  return lines.length > 0 ? lines : [''];
716
925
  }
717
926
 
927
+ function formatCompactTimestamp(value) {
928
+ const ms = parseTimestampMs(value);
929
+ if (ms) {
930
+ const date = new Date(ms);
931
+ const pad = (part) => String(part).padStart(2, '0');
932
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
933
+ }
934
+ const text = String(value || '').trim();
935
+ const match = text.match(/(?:(\d{4})[-/])?(\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{2})/);
936
+ if (match) {
937
+ return `${String(Number(match[2])).padStart(2, '0')}-${String(Number(match[3])).padStart(2, '0')} ${String(Number(match[4])).padStart(2, '0')}:${match[5]}`;
938
+ }
939
+ return '';
940
+ }
941
+
718
942
  function timeLabelForMessage(message) {
719
- return message.createdAtLabel || formatTimestampLabel(message.createdAt);
943
+ return formatCompactTimestamp(message.createdAt || message.timestampMs || message.createdAtLabel)
944
+ || message.createdAtLabel
945
+ || '';
720
946
  }
721
947
 
722
948
  function buildRenderItems(messages = []) {
@@ -741,76 +967,162 @@ function buildRenderItems(messages = []) {
741
967
  return items;
742
968
  }
743
969
 
744
- function estimateMessageLayout(message) {
745
- const maxColumns = message.side === 'local' ? 42 : 40;
746
- const lines = wrapText(message.text, maxColumns);
747
- const tagRows = message.tags.length > 0 ? 1 : 0;
748
- const textWidth = Math.min(
749
- 440,
750
- Math.max(160, Math.max(...lines.map(displayColumns), 10) * 8 + 34),
970
+ function renderSide(message = {}) {
971
+ return message.side === 'local' || message.side === 'right' || message.side === 'me'
972
+ ? 'right'
973
+ : 'left';
974
+ }
975
+
976
+ function participantLabel(message = {}, header = {}) {
977
+ if (renderSide(message) === 'right') return header.localLabel || 'You';
978
+ return message.speaker || header.peerLabel || 'Peer';
979
+ }
980
+
981
+ function messageTags(message = {}) {
982
+ return Array.isArray(message.tags) ? message.tags : [];
983
+ }
984
+
985
+ function measureRenderItem(item, width, header) {
986
+ const contentWidth = Math.max(270, Math.trunc(width * BUBBLE_MAX_RATIO));
987
+ if (item.kind === 'ellipsis') {
988
+ return {
989
+ kind: 'ellipsis',
990
+ message: null,
991
+ lines: [],
992
+ width: contentWidth,
993
+ height: ELLIPSIS_HEIGHT,
994
+ omittedCount: item.omitted || 0,
995
+ label: item.label || '',
996
+ };
997
+ }
998
+ if (item.kind === 'time') {
999
+ return {
1000
+ kind: 'time',
1001
+ message: null,
1002
+ lines: [],
1003
+ width: contentWidth,
1004
+ height: TIME_ROW_HEIGHT,
1005
+ label: item.label || '',
1006
+ };
1007
+ }
1008
+
1009
+ const side = renderSide(item.message);
1010
+ const label = participantLabel(item.message, header);
1011
+ const maxUnits = Math.max(18, (contentWidth - BUBBLE_PAD_X * 2) / TEXT_UNIT_PX);
1012
+ const lines = wrapText(item.message.text, maxUnits);
1013
+ const labelUnits = textUnits(labelText(label)) + 4;
1014
+ const contentUnits = Math.max(
1015
+ ...lines.map((line) => textUnits(line)),
1016
+ tagRowUnits(messageTags(item.message)),
1017
+ labelUnits,
1018
+ 10,
751
1019
  );
752
- const height = 24 + lines.length * 20 + tagRows * 25;
1020
+ const bubbleWidth = Math.trunc(Math.min(
1021
+ contentWidth,
1022
+ Math.max(BUBBLE_MIN_WIDTH, contentUnits * TEXT_UNIT_PX + BUBBLE_PAD_X * 2),
1023
+ ));
1024
+ const textHeight = lines.length * LINE_HEIGHT;
1025
+ const tagHeight = messageTags(item.message).length ? TAG_HEIGHT : 0;
1026
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + textHeight + tagHeight;
1027
+ const rowHeight = LABEL_HEIGHT - LABEL_OVERLAP + bubbleHeight + 10;
753
1028
  return {
1029
+ kind: 'message',
1030
+ message: item.message,
1031
+ side,
1032
+ participantLabel: label,
754
1033
  lines,
755
- width: textWidth,
756
- height,
1034
+ width: bubbleWidth,
1035
+ height: rowHeight,
1036
+ tagHeight,
1037
+ textHeight,
757
1038
  };
758
1039
  }
759
1040
 
760
1041
  function layoutPages({ messages, header, maxPageHeight }) {
761
1042
  const width = DEFAULT_WIDTH;
762
1043
  const maxHeight = maxPageHeight || DEFAULT_MAX_PAGE_HEIGHT;
763
- const margin = 32;
764
- const headerHeight = 102;
765
- const bottomMargin = 32;
766
- const pages = [];
767
- let page = {
768
- page: 1,
769
- width,
770
- height: Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, headerHeight + bottomMargin)),
771
- elements: [],
772
- };
773
- let y = headerHeight;
1044
+ const measuredItems = buildRenderItems(messages).map((item) => measureRenderItem(item, width, header));
1045
+ const pageBuckets = [[]];
1046
+ const headerHeight = headerHeightForSubtitle(header.subtitle);
1047
+ let used = headerHeight + BODY_TOP_GAP + PAGE_BOTTOM;
1048
+
1049
+ measuredItems.forEach((item, index) => {
1050
+ const itemHeight = item.height + ITEM_GAP;
1051
+ let neededHeight = itemHeight;
1052
+ if (item.kind === 'time' && index + 1 < measuredItems.length) {
1053
+ neededHeight += measuredItems[index + 1].height + ITEM_GAP;
1054
+ }
1055
+ const currentPage = pageBuckets[pageBuckets.length - 1];
1056
+ if (currentPage.length > 0 && used + neededHeight > maxHeight) {
1057
+ pageBuckets.push([]);
1058
+ used = headerHeight + BODY_TOP_GAP + PAGE_BOTTOM;
1059
+ }
1060
+ pageBuckets[pageBuckets.length - 1].push(item);
1061
+ used += itemHeight;
1062
+ });
774
1063
 
775
- const pushPage = () => {
776
- page.height = Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, Math.ceil(y + bottomMargin)));
777
- pages.push(page);
778
- page = {
779
- page: pages.length + 1,
1064
+ const pages = pageBuckets.map((pageItems, pageIndex) => {
1065
+ let y = headerHeight + BODY_TOP_GAP;
1066
+ const elements = [];
1067
+ for (const item of pageItems) {
1068
+ if (item.kind === 'ellipsis') {
1069
+ elements.push({
1070
+ kind: 'ellipsis',
1071
+ y,
1072
+ height: item.height,
1073
+ label: item.label,
1074
+ });
1075
+ y += item.height + ITEM_GAP;
1076
+ continue;
1077
+ }
1078
+ if (item.kind === 'time') {
1079
+ elements.push({
1080
+ kind: 'time',
1081
+ y,
1082
+ height: item.height,
1083
+ label: item.label,
1084
+ });
1085
+ y += item.height + ITEM_GAP;
1086
+ continue;
1087
+ }
1088
+ const label = labelText(item.participantLabel);
1089
+ const position = positions(width, item.width, label, item.side);
1090
+ const bubbleY = y + LABEL_HEIGHT - LABEL_OVERLAP;
1091
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + item.textHeight + item.tagHeight;
1092
+ elements.push({
1093
+ kind: 'message',
1094
+ y,
1095
+ x: position.bubbleX,
1096
+ bubbleX: position.bubbleX,
1097
+ bubbleY,
1098
+ labelX: position.labelX,
1099
+ labelY: y - LABEL_RAISE,
1100
+ labelWidth: position.labelWidth,
1101
+ label,
1102
+ align: position.align,
1103
+ width: item.width,
1104
+ height: bubbleHeight,
1105
+ bubbleHeight,
1106
+ lines: item.lines,
1107
+ message: item.message,
1108
+ side: item.side,
1109
+ participantLabel: item.participantLabel,
1110
+ tagHeight: item.tagHeight,
1111
+ textHeight: item.textHeight,
1112
+ });
1113
+ y += item.height + ITEM_GAP;
1114
+ }
1115
+ return {
1116
+ page: pageIndex + 1,
780
1117
  width,
781
- height: MIN_PAGE_HEIGHT,
782
- elements: [],
1118
+ height: Math.max(520, Math.min(maxHeight, Math.ceil(y + PAGE_BOTTOM))),
1119
+ elements,
1120
+ title: header.title,
1121
+ subtitle: header.subtitle,
1122
+ footer: 'visit claworld.love',
783
1123
  };
784
- y = 82;
785
- };
1124
+ });
786
1125
 
787
- for (const item of buildRenderItems(messages)) {
788
- if (item.kind === 'time') {
789
- const height = 28;
790
- if (y + height + bottomMargin > maxHeight && page.elements.length > 0) pushPage();
791
- page.elements.push({ ...item, x: margin, y, width: width - margin * 2, height });
792
- y += height;
793
- continue;
794
- }
795
- const messageLayout = estimateMessageLayout(item.message);
796
- const height = messageLayout.height + 10;
797
- if (y + height + bottomMargin > maxHeight && page.elements.length > 0) pushPage();
798
- const x = item.message.side === 'local'
799
- ? width - margin - messageLayout.width
800
- : margin;
801
- page.elements.push({
802
- kind: 'message',
803
- message: item.message,
804
- layout: messageLayout,
805
- x,
806
- y,
807
- width: messageLayout.width,
808
- height: messageLayout.height,
809
- });
810
- y += height;
811
- }
812
- page.height = Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, Math.ceil(y + bottomMargin)));
813
- pages.push(page);
814
1126
  return {
815
1127
  width,
816
1128
  header,
@@ -826,58 +1138,314 @@ function escapeXml(value) {
826
1138
  .replace(/"/g, '&quot;');
827
1139
  }
828
1140
 
829
- function renderSvgPage({ page, header, pageCount }) {
830
- const pageTitle = page.page === 1 ? header.title : `${header.title} (continued)`;
831
- const subtitle = page.page === 1 ? header.subtitle : `Page ${page.page} of ${pageCount}`;
1141
+ function renderSvgPage({ page }) {
1142
+ const titleId = `claworld-report-title-${page.page}`;
1143
+ const descId = `claworld-report-desc-${page.page}`;
1144
+ const desc = `${page.title}. ${page.subtitle}. ${page.elements.length} transcript rows.`;
832
1145
  const parts = [
833
- `<svg xmlns="http://www.w3.org/2000/svg" width="${page.width}" height="${page.height}" viewBox="0 0 ${page.width} ${page.height}" role="img" aria-label="${escapeXml(pageTitle)}">`,
834
- '<style>',
835
- '.bg{fill:#fbf7ef}.panel{fill:#fffdf8;stroke:#222;stroke-width:2}.title{font:700 26px Arial,sans-serif;fill:#161616}.subtitle{font:14px Arial,sans-serif;fill:#5f5b52}.label{font:700 12px Arial,sans-serif;fill:#666}.bubblePeer{fill:#ffffff;stroke:#222;stroke-width:2}.bubbleLocal{fill:#dff3e5;stroke:#1d3b29;stroke-width:2}.msg{font:15px Arial,sans-serif;fill:#171717}.time{font:12px Arial,sans-serif;fill:#736d63}.tag{font:700 11px Arial,sans-serif;fill:#161616}.tagBox{fill:#f6cf4f;stroke:#222;stroke-width:1}.tagEnd{fill:#f0b4a8}.tagLike{fill:#b9e3ff}.tagDislike{fill:#ddd}.shadow{fill:#222;opacity:.16}',
836
- '</style>',
837
- `<rect class="bg" x="0" y="0" width="${page.width}" height="${page.height}"/>`,
838
- `<rect class="panel" x="22" y="18" width="${page.width - 44}" height="${page.height - 36}" rx="18"/>`,
839
- `<text class="title" x="42" y="56">${escapeXml(pageTitle)}</text>`,
840
- `<text class="subtitle" x="42" y="80">${escapeXml(subtitle || 'Claworld transcript')}</text>`,
1146
+ `<svg class="comic-grid" xmlns="http://www.w3.org/2000/svg" width="${page.width}" height="${page.height}" viewBox="0 0 ${page.width} ${page.height}" role="img" aria-labelledby="${titleId} ${descId}">`,
1147
+ `<title id="${titleId}">${escapeXml(page.title)}</title>`,
1148
+ `<desc id="${descId}">${escapeXml(desc)}</desc>`,
1149
+ svgDefs(),
1150
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="${COMIC_THEME.paper}"/>`,
1151
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMinor)"/>`,
1152
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMajor)" opacity="0.46"/>`,
1153
+ `<rect x="${FRAME_MARGIN}" y="${FRAME_MARGIN}" width="${page.width - FRAME_MARGIN * 2}" height="${page.height - FRAME_MARGIN * 2}" rx="46" fill="none" stroke="${BLACK}" stroke-width="6"/>`,
1154
+ renderHeaderSvg(page),
1155
+ '<g role="list">',
841
1156
  ];
842
-
843
- for (const element of page.elements) {
844
- if (element.kind === 'time') {
845
- parts.push(`<text class="time" x="${page.width / 2}" y="${element.y + 18}" text-anchor="middle">${escapeXml(element.label)}</text>`);
1157
+ for (const item of page.elements) {
1158
+ if (item.kind === 'ellipsis') {
1159
+ parts.push(renderEllipsisSvg(page, item));
846
1160
  continue;
847
1161
  }
848
- const { message, layout, x, y, width, height } = element;
849
- const bubbleClass = message.side === 'local' ? 'bubbleLocal' : 'bubblePeer';
850
- const label = message.side === 'local' ? header.localLabel : (message.speaker || header.peerLabel);
851
- parts.push(`<text class="label" x="${x}" y="${y + 10}">${escapeXml(label)}</text>`);
852
- parts.push(`<rect class="shadow" x="${x + 4}" y="${y + 18}" width="${width}" height="${height}" rx="16"/>`);
853
- parts.push(`<rect class="${bubbleClass}" x="${x}" y="${y + 14}" width="${width}" height="${height}" rx="16"/>`);
854
- const textX = x + 17;
855
- let textY = y + 40;
856
- parts.push(`<text class="msg" x="${textX}" y="${textY}">`);
857
- for (const [index, line] of layout.lines.entries()) {
858
- parts.push(`<tspan x="${textX}" dy="${index === 0 ? 0 : 20}">${escapeXml(line)}</tspan>`);
859
- }
860
- parts.push('</text>');
861
- textY += layout.lines.length * 20 + 4;
862
- let tagX = textX;
863
- for (const tag of message.tags) {
864
- const tagWidth = Math.max(48, displayColumns(tag) * 7 + 24);
865
- const className = tag === 'request end'
866
- ? 'tagEnd'
867
- : tag === 'like'
868
- ? 'tagLike'
869
- : tag === 'dislike'
870
- ? 'tagDislike'
871
- : 'tagBox';
872
- parts.push(`<rect class="${className}" x="${tagX}" y="${textY}" width="${tagWidth}" height="18" rx="9"/>`);
873
- parts.push(`<text class="tag" x="${tagX + 12}" y="${textY + 13}">${escapeXml(tag)}</text>`);
874
- tagX += tagWidth + 6;
1162
+ if (item.kind === 'time') {
1163
+ parts.push(renderTimeSvg(page, item));
1164
+ continue;
875
1165
  }
1166
+ parts.push(renderMessageSvg(item));
1167
+ }
1168
+ parts.push('</g>');
1169
+ if (page.footer) {
1170
+ parts.push(`<text x="${(page.width / 2).toFixed(1)}" y="${page.height - 24}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#444444">${escapeXml(page.footer)}</text>`);
876
1171
  }
877
1172
  parts.push('</svg>');
878
1173
  return parts.join('\n');
879
1174
  }
880
1175
 
1176
+ function positions(width, bubbleWidth, label, side) {
1177
+ const labelWidth = labelWidthForText(label);
1178
+ const inset = CANVAS_MARGIN + 38;
1179
+ if (side === 'right') {
1180
+ const bubbleX = width - inset - bubbleWidth;
1181
+ const labelX = bubbleX + bubbleWidth - labelWidth - 16;
1182
+ return { bubbleX, labelX, labelWidth, align: 'right' };
1183
+ }
1184
+ const bubbleX = inset;
1185
+ const labelX = bubbleX + 16;
1186
+ return { bubbleX, labelX, labelWidth, align: 'left' };
1187
+ }
1188
+
1189
+ function renderHeaderSvg(page) {
1190
+ const x = CANVAS_MARGIN + 26;
1191
+ const y = HEADER_Y;
1192
+ const width = page.width - (CANVAS_MARGIN + 26) * 2;
1193
+ const height = headerCardHeight(page.subtitle);
1194
+ const title = clipDisplay(headerTitle(page.title), 32);
1195
+ return [
1196
+ `<rect x="${x + 11}" y="${y + 6}" width="${width + 2}" height="${height + 10}" rx="22" fill="${BLACK}"/>`,
1197
+ `<rect x="${x + 7}" y="${y + 6}" width="${width}" height="${height + 4}" rx="22" fill="url(#headerAccent)"/>`,
1198
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="22" fill="${COMIC_THEME.headerFill}" stroke="${BLACK}" stroke-width="4"/>`,
1199
+ `<text x="${x + 28}" y="${y + 43}" font-size="${TITLE_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(title)}</text>`,
1200
+ renderHeaderSubtitleSvg(x + 35, y + 70, headerSubtitleLines(page.subtitle)),
1201
+ decorativeStarSvg(x + width - 62, y + 34, 22, '#FFFFFF', 'url(#headerAccent)'),
1202
+ `<circle cx="${x + width - 23}" cy="${y + 55}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
1203
+ `<circle cx="${x + width - 25}" cy="${y + 53}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
1204
+ ].join('\n');
1205
+ }
1206
+
1207
+ function headerSubtitleLines(subtitle) {
1208
+ const lines = wrapText(String(subtitle || '').trim(), HEADER_SUBTITLE_MAX_UNITS);
1209
+ if (lines.length <= HEADER_SUBTITLE_MAX_LINES) return lines;
1210
+ const visible = lines.slice(0, HEADER_SUBTITLE_MAX_LINES - 1);
1211
+ const remainder = lines.slice(HEADER_SUBTITLE_MAX_LINES - 1).map((line) => line.trim()).filter(Boolean).join(' ');
1212
+ visible.push(ellipsizeText(remainder, HEADER_SUBTITLE_MAX_UNITS));
1213
+ return visible;
1214
+ }
1215
+
1216
+ function headerCardHeight(subtitle) {
1217
+ return headerSubtitleLines(subtitle).length > 1
1218
+ ? HEADER_CARD_HEIGHT_TWO_LINES
1219
+ : HEADER_CARD_HEIGHT_ONE_LINE;
1220
+ }
1221
+
1222
+ function headerHeightForSubtitle(subtitle) {
1223
+ return HEADER_Y + headerCardHeight(subtitle) + HEADER_BOTTOM_PAD;
1224
+ }
1225
+
1226
+ function renderHeaderSubtitleSvg(x, y, lines) {
1227
+ return lines.map((line, index) => (
1228
+ `<text class="header-subtitle-line" x="${x.toFixed(1)}" y="${(y + index * HEADER_SUBTITLE_LINE_HEIGHT).toFixed(1)}" font-size="15" font-weight="600" fill="${COMIC_THEME.muted}">${escapeXml(line)}</text>`
1229
+ )).join('\n');
1230
+ }
1231
+
1232
+ function renderEllipsisSvg(page, item) {
1233
+ const y = item.y + 7;
1234
+ return `<text x="${(page.width / 2).toFixed(1)}" y="${y + 14}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#555555">${escapeXml(item.label)}</text>`;
1235
+ }
1236
+
1237
+ function renderTimeSvg(page, item) {
1238
+ const label = clipDisplay(item.label, 22);
1239
+ const labelWidth = Math.max(150, displayColumns(label) * 8 + 44);
1240
+ const x = page.width / 2 - labelWidth / 2;
1241
+ const y = item.y + 4;
1242
+ return [
1243
+ '<g class="time-row">',
1244
+ diamondSvg(x - 28, y + 16, 13, COMIC_THEME.timeAccentLeft),
1245
+ `<rect x="${(x + 3).toFixed(1)}" y="${y + 4}" width="${labelWidth}" height="30" rx="15" fill="${BLACK}"/>`,
1246
+ `<rect x="${x.toFixed(1)}" y="${y}" width="${labelWidth}" height="30" rx="15" fill="${COMIC_THEME.timeFill}" stroke="${BLACK}" stroke-width="3"/>`,
1247
+ `<text x="${(page.width / 2).toFixed(1)}" y="${y + 21}" text-anchor="middle" font-size="${FONT_SIZE}" font-weight="700" fill="${BLACK}">${escapeXml(label)}</text>`,
1248
+ diamondSvg(x + labelWidth + 28, y + 16, 13, COMIC_THEME.timeAccentRight),
1249
+ '</g>',
1250
+ ].join('\n');
1251
+ }
1252
+
1253
+ function renderMessageSvg(item) {
1254
+ const colors = sideColors(item.side);
1255
+ const messageText = item.message?.text || '';
1256
+ const titleText = escapeXml(`${item.participantLabel}: ${ellipsizeText(messageText, 42)}`);
1257
+ const parts = [
1258
+ `<g class="message-row ${item.side}" role="listitem" aria-label="${titleText}">`,
1259
+ `<title>${titleText}</title>`,
1260
+ bubbleLayersSvg(item, colors),
1261
+ `<rect x="${item.labelX}" y="${item.labelY}" width="${item.labelWidth}" height="${LABEL_HEIGHT}" rx="9" fill="${colors.label}" stroke="${BLACK}" stroke-width="3"/>`,
1262
+ `<text x="${(item.labelX + item.labelWidth / 2).toFixed(1)}" y="${item.labelY + 21}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(item.label)}</text>`,
1263
+ ];
1264
+ const textX = item.bubbleX + BUBBLE_PAD_X;
1265
+ let textY = item.bubbleY + BUBBLE_PAD_Y + 17;
1266
+ for (const line of item.lines) {
1267
+ parts.push(`<text x="${textX}" y="${textY}" font-size="${FONT_SIZE}" font-weight="800" fill="${BLACK}">${escapeXml(line)}</text>`);
1268
+ textY += LINE_HEIGHT;
1269
+ }
1270
+ const tags = messageTags(item.message);
1271
+ if (tags.length) parts.push(renderTagIconsSvg(tags, textX, textY + TAG_ICON_TOP_GAP));
1272
+ parts.push('</g>');
1273
+ return parts.join('\n');
1274
+ }
1275
+
1276
+ function bubbleLayersSvg(item, colors) {
1277
+ const x = item.bubbleX;
1278
+ const y = item.bubbleY;
1279
+ const width = item.width;
1280
+ const height = item.bubbleHeight;
1281
+ return [
1282
+ `<rect x="${x + 11}" y="${y + 9}" width="${width + 2}" height="${height + 4}" rx="17" fill="${BLACK}"/>`,
1283
+ `<rect x="${x + 11}" y="${y + 9}" width="${width - 3}" height="${height}" rx="17" fill="${colors.accent}" stroke="${BLACK}" stroke-width="3"/>`,
1284
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="17" fill="${colors.fill}" stroke="${BLACK}" stroke-width="4"/>`,
1285
+ ].join('\n');
1286
+ }
1287
+
1288
+ function sideColors(side) {
1289
+ if (side === 'right') {
1290
+ return {
1291
+ fill: COMIC_THEME.rightFill,
1292
+ label: COMIC_THEME.rightLabel,
1293
+ accent: 'url(#rightAccent)',
1294
+ };
1295
+ }
1296
+ return {
1297
+ fill: COMIC_THEME.leftFill,
1298
+ label: COMIC_THEME.leftLabel,
1299
+ accent: 'url(#leftAccent)',
1300
+ };
1301
+ }
1302
+
1303
+ function headerTitle(title) {
1304
+ const clean = String(title || '').trim();
1305
+ if (clean.startsWith('@')) return clean;
1306
+ return clean ? `@${clean}` : '@claworld';
1307
+ }
1308
+
1309
+ function labelText(label) {
1310
+ return clipDisplay(String(label || 'AGENT').toUpperCase(), LABEL_MAX_COLS);
1311
+ }
1312
+
1313
+ function labelWidthForText(label) {
1314
+ return Math.max(86, Math.min(158, displayColumns(label) * 11 + 30));
1315
+ }
1316
+
1317
+ function tagRowUnits(tags) {
1318
+ if (!tags.length) return 0;
1319
+ const width = tags.reduce((sum, tag) => sum + tagWidth(tag), 0) + Math.max(0, tags.length - 1) * TAG_ICON_GAP;
1320
+ return width / TEXT_UNIT_PX;
1321
+ }
1322
+
1323
+ function renderTagIconsSvg(tags, x, y) {
1324
+ const parts = [`<g class="tag-icons" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})">`];
1325
+ let cursor = 0;
1326
+ for (const tag of tags) {
1327
+ parts.push(tagIconSvg(tag, cursor, 0));
1328
+ cursor += tagWidth(tag) + TAG_ICON_GAP;
1329
+ }
1330
+ parts.push('</g>');
1331
+ return parts.join('\n');
1332
+ }
1333
+
1334
+ function tagWidth(tag) {
1335
+ const normalized = tagName(tag);
1336
+ if (['like', 'dislike', 'request end'].includes(normalized)) return TAG_ICON_SIZE;
1337
+ return Math.max(58, Math.min(126, displayColumns(fallbackTagLabel(normalized)) * 8 + 24));
1338
+ }
1339
+
1340
+ function tagIconSvg(tag, x, y) {
1341
+ const normalized = tagName(tag);
1342
+ if (!['like', 'dislike', 'request end'].includes(normalized)) return fallbackTagSvg(normalized, x, y);
1343
+ const [fill, accent] = TAG_ICON_THEMES[normalized] || ['#FFFFFF', '#7DD7FF'];
1344
+ const icon = normalized === 'request end'
1345
+ ? requestEndIconSvg(x, y, accent)
1346
+ : thumbIconSvg(x, y, accent, { down: normalized === 'dislike' });
1347
+ return [
1348
+ `<g class="tag-icon tag-${safeClassToken(normalized)}" role="img" aria-label="${escapeXml(normalized)}">`,
1349
+ `<title>${escapeXml(normalized)}</title>`,
1350
+ `<rect x="${(x + 3).toFixed(1)}" y="${(y + 4).toFixed(1)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
1351
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`,
1352
+ icon,
1353
+ '</g>',
1354
+ ].join('\n');
1355
+ }
1356
+
1357
+ function tagName(tag) {
1358
+ return String(tag || '').trim().toLowerCase();
1359
+ }
1360
+
1361
+ function fallbackTagLabel(tag) {
1362
+ return clipDisplay(String(tag || 'tag'), TAG_FALLBACK_MAX_COLS);
1363
+ }
1364
+
1365
+ function fallbackTagSvg(tag, x, y) {
1366
+ const label = fallbackTagLabel(tag);
1367
+ const width = tagWidth(tag);
1368
+ return [
1369
+ `<g class="tag-icon tag-fallback" role="img" aria-label="${escapeXml(label)}">`,
1370
+ `<title>${escapeXml(label)}</title>`,
1371
+ `<rect x="${(x + 3).toFixed(1)}" y="${(y + 4).toFixed(1)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
1372
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="#F6F1FF" stroke="${BLACK}" stroke-width="2.5"/>`,
1373
+ `<text x="${(x + width / 2).toFixed(1)}" y="${(y + 20.5).toFixed(1)}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(label)}</text>`,
1374
+ '</g>',
1375
+ ].join('\n');
1376
+ }
1377
+
1378
+ function thumbIconSvg(x, y, accent, { down = false } = {}) {
1379
+ const paths = [
1380
+ `<path d="M${(x + 7.8).toFixed(1)} ${(y + 13.3).toFixed(1)} H${(x + 12).toFixed(1)} V${(y + 24).toFixed(1)} H${(x + 7.8).toFixed(1)} Z" fill="#FFFFFF" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1381
+ `<path d="M${(x + 12).toFixed(1)} ${(y + 23.6).toFixed(1)} H${(x + 21.2).toFixed(1)} C${(x + 23).toFixed(1)} ${(y + 23.6).toFixed(1)} ${(x + 24).toFixed(1)} ${(y + 22.5).toFixed(1)} ${(x + 24.4).toFixed(1)} ${(y + 20.9).toFixed(1)} L${(x + 25.4).toFixed(1)} ${(y + 15.6).toFixed(1)} C${(x + 25.8).toFixed(1)} ${(y + 13.9).toFixed(1)} ${(x + 24.5).toFixed(1)} ${(y + 12.2).toFixed(1)} ${(x + 22.7).toFixed(1)} ${(y + 12.2).toFixed(1)} H${(x + 18.6).toFixed(1)} L${(x + 19.2).toFixed(1)} ${(y + 9.1).toFixed(1)} C${(x + 19.5).toFixed(1)} ${(y + 7.4).toFixed(1)} ${(x + 18.4).toFixed(1)} ${(y + 5.7).toFixed(1)} ${(x + 16.7).toFixed(1)} ${(y + 5.4).toFixed(1)} L${(x + 15.8).toFixed(1)} ${(y + 5.3).toFixed(1)} L${(x + 12).toFixed(1)} ${(y + 12.9).toFixed(1)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1382
+ ].join('\n');
1383
+ if (!down) return paths;
1384
+ const cx = x + TAG_ICON_SIZE / 2;
1385
+ const cy = y + TAG_ICON_SIZE / 2;
1386
+ return `<g transform="rotate(180 ${cx.toFixed(1)} ${cy.toFixed(1)})">\n${paths}\n</g>`;
1387
+ }
1388
+
1389
+ function requestEndIconSvg(x, y, accent) {
1390
+ return [
1391
+ `<path d="M${(x + 22).toFixed(1)} ${(y + 4).toFixed(1)} C${(x + 25.2).toFixed(1)} ${(y + 5.4).toFixed(1)} ${(x + 26.6).toFixed(1)} ${(y + 7.8).toFixed(1)} ${(x + 26.5).toFixed(1)} ${(y + 10.6).toFixed(1)}" fill="none" stroke="${BLACK}" stroke-width="2" stroke-linecap="round"/>`,
1392
+ `<path d="M${(x + 9.215).toFixed(3)} ${(y + 18.117).toFixed(3)} C${(x + 10.043).toFixed(3)} ${(y + 22.457).toFixed(3)} ${(x + 13.384).toFixed(3)} ${(y + 25.036).toFixed(3)} ${(x + 17.132).toFixed(3)} ${(y + 23.961).toFixed(3)} L${(x + 19.44).toFixed(3)} ${(y + 23.3).toFixed(3)} C${(x + 22.323).toFixed(3)} ${(y + 22.473).toFixed(3)} ${(x + 23.488).toFixed(3)} ${(y + 19.642).toFixed(3)} ${(x + 22.538).toFixed(3)} ${(y + 16.69).toFixed(3)} L${(x + 21.435).toFixed(3)} ${(y + 12.845).toFixed(3)} L${(x + 9.227).toFixed(3)} ${(y + 16.345).toFixed(3)} L${(x + 9.215).toFixed(3)} ${(y + 18.117).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1393
+ `<path d="M${(x + 9.665).toFixed(3)} ${(y + 7.897).toFixed(3)} L${(x + 9.473).toFixed(3)} ${(y + 7.952).toFixed(3)} C${(x + 8.756).toFixed(3)} ${(y + 8.158).toFixed(3)} ${(x + 8.286).toFixed(3)} ${(y + 8.709).toFixed(3)} ${(x + 8.422).toFixed(3)} ${(y + 9.184).toFixed(3)} L${(x + 10.192).toFixed(3)} ${(y + 15.356).toFixed(3)} C${(x + 10.328).toFixed(3)} ${(y + 15.831).toFixed(3)} ${(x + 11.019).toFixed(3)} ${(y + 16.049).toFixed(3)} ${(x + 11.736).toFixed(3)} ${(y + 15.843).toFixed(3)} L${(x + 11.928).toFixed(3)} ${(y + 15.788).toFixed(3)} C${(x + 12.645).toFixed(3)} ${(y + 15.583).toFixed(3)} ${(x + 13.115).toFixed(3)} ${(y + 15.031).toFixed(3)} ${(x + 12.979).toFixed(3)} ${(y + 14.557).toFixed(3)} L${(x + 11.209).toFixed(3)} ${(y + 8.384).toFixed(3)} C${(x + 11.073).toFixed(3)} ${(y + 7.91).toFixed(3)} ${(x + 10.382).toFixed(3)} ${(y + 7.692).toFixed(3)} ${(x + 9.665).toFixed(3)} ${(y + 7.897).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1394
+ `<path d="M${(x + 11.93).toFixed(3)} ${(y + 5.999).toFixed(3)} L${(x + 11.738).toFixed(3)} ${(y + 6.055).toFixed(3)} C${(x + 11.021).toFixed(3)} ${(y + 6.26).toFixed(3)} ${(x + 10.553).toFixed(3)} ${(y + 6.82).toFixed(3)} ${(x + 10.692).toFixed(3)} ${(y + 7.306).toFixed(3)} L${(x + 12.729).toFixed(3)} ${(y + 14.408).toFixed(3)} C${(x + 12.868).toFixed(3)} ${(y + 14.894).toFixed(3)} ${(x + 13.562).toFixed(3)} ${(y + 15.122).toFixed(3)} ${(x + 14.279).toFixed(3)} ${(y + 14.916).toFixed(3)} L${(x + 14.471).toFixed(3)} ${(y + 14.861).toFixed(3)} C${(x + 15.188).toFixed(3)} ${(y + 14.655).toFixed(3)} ${(x + 15.656).toFixed(3)} ${(y + 14.095).toFixed(3)} ${(x + 15.516).toFixed(3)} ${(y + 13.609).toFixed(3)} L${(x + 13.48).toFixed(3)} ${(y + 6.507).toFixed(3)} C${(x + 13.341).toFixed(3)} ${(y + 6.021).toFixed(3)} ${(x + 12.647).toFixed(3)} ${(y + 5.794).toFixed(3)} ${(x + 11.93).toFixed(3)} ${(y + 5.999).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1395
+ `<path d="M${(x + 14.636).toFixed(3)} ${(y + 5.64).toFixed(3)} L${(x + 14.443).toFixed(3)} ${(y + 5.695).toFixed(3)} C${(x + 13.727).toFixed(3)} ${(y + 5.9).toFixed(3)} ${(x + 13.258).toFixed(3)} ${(y + 6.457).toFixed(3)} ${(x + 13.396).toFixed(3)} ${(y + 6.938).toFixed(3)} L${(x + 15.338).toFixed(3)} ${(y + 13.714).toFixed(3)} C${(x + 15.476).toFixed(3)} ${(y + 14.195).toFixed(3)} ${(x + 16.169).toFixed(3)} ${(y + 14.418).toFixed(3)} ${(x + 16.886).toFixed(3)} ${(y + 14.213).toFixed(3)} L${(x + 17.078).toFixed(3)} ${(y + 14.157).toFixed(3)} C${(x + 17.795).toFixed(3)} ${(y + 13.952).toFixed(3)} ${(x + 18.264).toFixed(3)} ${(y + 13.395).toFixed(3)} ${(x + 18.126).toFixed(3)} ${(y + 12.914).toFixed(3)} L${(x + 16.183).toFixed(3)} ${(y + 6.139).toFixed(3)} C${(x + 16.045).toFixed(3)} ${(y + 5.658).toFixed(3)} ${(x + 15.352).toFixed(3)} ${(y + 5.434).toFixed(3)} ${(x + 14.636).toFixed(3)} ${(y + 5.64).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1396
+ `<path d="M${(x + 17.8).toFixed(3)} ${(y + 6.085).toFixed(3)} L${(x + 17.702).toFixed(3)} ${(y + 6.113).toFixed(3)} C${(x + 16.971).toFixed(3)} ${(y + 6.322).toFixed(3)} ${(x + 16.482).toFixed(3)} ${(y + 6.851).toFixed(3)} ${(x + 16.609).toFixed(3)} ${(y + 7.294).toFixed(3)} L${(x + 18.176).toFixed(3)} ${(y + 12.759).toFixed(3)} C${(x + 18.303).toFixed(3)} ${(y + 13.202).toFixed(3)} ${(x + 18.998).toFixed(3)} ${(y + 13.391).toFixed(3)} ${(x + 19.729).toFixed(3)} ${(y + 13.182).toFixed(3)} L${(x + 19.827).toFixed(3)} ${(y + 13.154).toFixed(3)} C${(x + 20.557).toFixed(3)} ${(y + 12.944).toFixed(3)} ${(x + 21.046).toFixed(3)} ${(y + 12.415).toFixed(3)} ${(x + 20.919).toFixed(3)} ${(y + 11.972).toFixed(3)} L${(x + 19.352).toFixed(3)} ${(y + 6.507).toFixed(3)} C${(x + 19.225).toFixed(3)} ${(y + 6.065).toFixed(3)} ${(x + 18.53).toFixed(3)} ${(y + 5.875).toFixed(3)} ${(x + 17.8).toFixed(3)} ${(y + 6.085).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1397
+ `<path d="M${(x + 9.546).toFixed(3)} ${(y + 19.999).toFixed(3)} L${(x + 6).toFixed(3)} ${(y + 17.791).toFixed(3)} C${(x + 4.736).toFixed(3)} ${(y + 17.009).toFixed(3)} ${(x + 5.668).toFixed(3)} ${(y + 15.181).toFixed(3)} ${(x + 7.138).toFixed(3)} ${(y + 15.592).toFixed(3)} L${(x + 10.615).toFixed(3)} ${(y + 17.196).toFixed(3)} L${(x + 9.546).toFixed(3)} ${(y + 19.999).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8" stroke-linejoin="round"/>`,
1398
+ `<rect x="${(x + 10.887).toFixed(3)}" y="${(y + 14.834).toFixed(3)}" width="9.584" height="3.800" transform="rotate(-16.9438 ${(x + 10.887).toFixed(3)} ${(y + 14.834).toFixed(3)})" fill="${accent}"/>`,
1399
+ `<path d="M${(x + 10.5).toFixed(1)} ${(y + 19.5).toFixed(1)} L${(x + 11).toFixed(1)} ${(y + 20).toFixed(1)} L${(x + 11.5).toFixed(1)} ${(y + 17).toFixed(1)} L${(x + 9.5).toFixed(1)} ${(y + 17.5).toFixed(1)} L${(x + 9).toFixed(1)} ${(y + 18.5).toFixed(1)} L${(x + 10.5).toFixed(1)} ${(y + 19.5).toFixed(1)} Z" fill="${accent}"/>`,
1400
+ ].join('\n');
1401
+ }
1402
+
1403
+ function decorativeStarSvg(cx, cy, radius, fill, accent) {
1404
+ const backPath = starPoints(cx + 3, cy + 5, radius).map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
1405
+ const frontPath = starPoints(cx, cy, radius).map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
1406
+ return [
1407
+ `<polygon points="${backPath}" fill="${accent}"/>`,
1408
+ `<polygon points="${frontPath}" fill="${fill}" stroke="${BLACK}" stroke-width="3"/>`,
1409
+ ].join('\n');
1410
+ }
1411
+
1412
+ function diamondSvg(cx, cy, radius, fill) {
1413
+ const shadow = `<polygon points="${(cx + 1).toFixed(1)},${(cy + 2 - radius).toFixed(1)} ${(cx + 1 + radius * 0.46).toFixed(1)},${(cy + 2).toFixed(1)} ${(cx + 1).toFixed(1)},${(cy + 2 + radius).toFixed(1)} ${(cx + 1 - radius * 0.46).toFixed(1)},${(cy + 2).toFixed(1)}" fill="${BLACK}" stroke="${BLACK}" stroke-width="2.5"/>`;
1414
+ const front = `<polygon points="${cx.toFixed(1)},${(cy - radius).toFixed(1)} ${(cx + radius * 0.46).toFixed(1)},${cy.toFixed(1)} ${cx.toFixed(1)},${(cy + radius).toFixed(1)} ${(cx - radius * 0.46).toFixed(1)},${cy.toFixed(1)}" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`;
1415
+ return `${shadow}\n${front}`;
1416
+ }
1417
+
1418
+ function starPoints(cx, cy, radius) {
1419
+ return [
1420
+ [cx, cy - radius],
1421
+ [cx + radius * 0.28, cy - radius * 0.28],
1422
+ [cx + radius, cy],
1423
+ [cx + radius * 0.28, cy + radius * 0.28],
1424
+ [cx, cy + radius],
1425
+ [cx - radius * 0.28, cy + radius * 0.28],
1426
+ [cx - radius, cy],
1427
+ [cx - radius * 0.28, cy - radius * 0.28],
1428
+ ];
1429
+ }
1430
+
1431
+ function safeClassToken(value) {
1432
+ return String(value || 'tag').toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'tag';
1433
+ }
1434
+
1435
+ function svgDefs() {
1436
+ return [
1437
+ '<defs>',
1438
+ `<style><![CDATA[text { font-family: ${COMIC_FONT_FAMILY}; letter-spacing: 0; } .message-row:hover rect:last-of-type { filter: url(#comicLift); }]]></style>`,
1439
+ '<pattern id="comicGridMinor" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#BED1D8" stroke-width="1" stroke-opacity="0.62"/></pattern>',
1440
+ '<pattern id="comicGridMajor" width="128" height="128" patternUnits="userSpaceOnUse"><path d="M 128 0 L 0 0 0 128" fill="none" stroke="#AABFC8" stroke-width="1.4" stroke-opacity="0.72"/></pattern>',
1441
+ '<linearGradient id="headerAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#47B6FF"/><stop offset="52%" stop-color="#FF4EB4"/><stop offset="100%" stop-color="#FF8A2A"/></linearGradient>',
1442
+ '<linearGradient id="leftAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#58E58F"/><stop offset="100%" stop-color="#47B6FF"/></linearGradient>',
1443
+ '<linearGradient id="rightAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#A871FF"/><stop offset="100%" stop-color="#FF4EB4"/></linearGradient>',
1444
+ '<filter id="comicLift" x="-6%" y="-16%" width="112%" height="132%"><feDropShadow dx="0" dy="2" stdDeviation="1.2" flood-color="#000000" flood-opacity="0.14"/></filter>',
1445
+ '</defs>',
1446
+ ].join('\n');
1447
+ }
1448
+
881
1449
  function buildBubbleSpec({ mode, source, header, messages, layout, style }) {
882
1450
  return {
883
1451
  schema: ARTIFACT_SCHEMA,
@@ -1043,11 +1611,13 @@ async function writeArtifacts({ workspaceRoot, artifactId, spec, layout }) {
1043
1611
  page: page.page,
1044
1612
  format: 'png',
1045
1613
  path: pngPath,
1614
+ filePath: pngPath,
1615
+ filename: path.basename(pngPath),
1616
+ mimeType: 'image/png',
1046
1617
  width: page.width,
1047
1618
  height: page.height,
1048
1619
  renderer,
1049
1620
  sha256: await hashFile(pngPath),
1050
- mediaRef: `MEDIA:${pngPath}`,
1051
1621
  });
1052
1622
  }
1053
1623
  return {
@@ -1061,6 +1631,19 @@ async function writeArtifacts({ workspaceRoot, artifactId, spec, layout }) {
1061
1631
  };
1062
1632
  }
1063
1633
 
1634
+ function buildOpenClawUploadArgs(page, message = null) {
1635
+ if (!page?.path) return null;
1636
+ return {
1637
+ action: 'upload-file',
1638
+ filePath: page.path,
1639
+ path: page.path,
1640
+ media: page.path,
1641
+ filename: page.filename || path.basename(page.path),
1642
+ mimeType: page.mimeType || 'image/png',
1643
+ ...(message ? { message } : {}),
1644
+ };
1645
+ }
1646
+
1064
1647
  function buildDiagnostics({ sourceRecords, normalizedMessages, renderedMessages, source }) {
1065
1648
  return {
1066
1649
  source: {
@@ -1168,10 +1751,16 @@ export async function renderClaworldTranscriptReport({
1168
1751
  style: params.style,
1169
1752
  artifacts,
1170
1753
  deliveryHint: {
1171
- primaryMedia: artifacts.pngPages[0]?.mediaRef || null,
1172
- primaryMediaBatch: artifacts.pngPages.map((page) => page.mediaRef).join('\n'),
1173
- sourceSvgDocument: artifacts.svgPages[0] ? `[[as_document]]\nMEDIA:${artifacts.svgPages[0].path}` : null,
1174
- openClawMediaPaths: artifacts.pngPages.map((page) => page.path),
1754
+ primaryFilePath: artifacts.pngPages[0]?.path || null,
1755
+ imageFilePaths: artifacts.pngPages.map((page) => page.path),
1756
+ sourceSvgFilePaths: artifacts.svgPages.map((page) => page.path),
1757
+ messageTool: buildOpenClawUploadArgs(
1758
+ artifacts.pngPages[0],
1759
+ source.chatRequestId
1760
+ ? `Claworld transcript report for ${source.chatRequestId}`
1761
+ : 'Claworld transcript report',
1762
+ ),
1763
+ messageToolBatch: artifacts.pngPages.map((page) => buildOpenClawUploadArgs(page)).filter(Boolean),
1175
1764
  },
1176
1765
  diagnostics: buildDiagnostics({
1177
1766
  sourceRecords,