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

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.
@@ -1,1898 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import { promisify } from 'node:util';
6
- import { deflateSync } from 'node:zlib';
7
- import { createRuntimeBoundaryError } from '../../lib/runtime-errors.js';
8
- import {
9
- appendClaworldJournalEvent,
10
- CLAWORLD_REPORTS_DIR,
11
- CLAWORLD_WORKING_MEMORY_DIR,
12
- ensureClaworldWorkingMemory,
13
- readClaworldSessionDirectory,
14
- } from './working-memory.js';
15
-
16
- const execFileAsync = promisify(execFile);
17
-
18
- export const CLAWORLD_TRANSCRIPT_REPORT_TOOL_NAME = 'claworld_render_transcript_report';
19
- export const CLAWORLD_TRANSCRIPT_REPORT_STYLE = 'claworld-comic-grid';
20
- export const CLAWORLD_TRANSCRIPT_REPORT_STYLES = Object.freeze([
21
- CLAWORLD_TRANSCRIPT_REPORT_STYLE,
22
- ]);
23
-
24
- const DEFAULT_WIDTH = 720;
25
- const DEFAULT_MAX_PAGE_HEIGHT = 2600;
26
- const MIN_PAGE_HEIGHT = 900;
27
- const MAX_PAGE_HEIGHT = 8000;
28
- const TOP_LEVEL_RENDER_FIELDS = new Set(['mode', 'stored', 'manual', 'style', 'maxPageHeight']);
29
- const MANUAL_RENDER_FIELDS = new Set(['messages', 'title', 'peerProfile', 'localLabel', 'peerLabel']);
30
- const MANUAL_MESSAGE_FIELDS = new Set(['from', 'text', 'createdAt']);
31
- const TIME_SPLIT_SECONDS = 5 * 60;
32
- const SEGMENT_GAP_SECONDS = 4 * 60 * 60;
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(', ');
110
-
111
- function isObject(value) {
112
- return value && typeof value === 'object' && !Array.isArray(value);
113
- }
114
-
115
- function normalizeText(value, fallback = null) {
116
- if (value == null) return fallback;
117
- const normalized = String(value).trim();
118
- return normalized || fallback;
119
- }
120
-
121
- function inputError(field, message) {
122
- throw createRuntimeBoundaryError({
123
- code: 'tool_input_invalid',
124
- category: 'input',
125
- status: 400,
126
- message,
127
- publicMessage: message,
128
- recoverable: true,
129
- context: { field },
130
- });
131
- }
132
-
133
- function assertObject(value, field) {
134
- if (!isObject(value)) inputError(field, `${field} must be an object`);
135
- return value;
136
- }
137
-
138
- function assertNoUnknownFields(value, allowedFields, field) {
139
- const source = assertObject(value, field);
140
- for (const key of Object.keys(source)) {
141
- if (!allowedFields.has(key)) inputError(`${field}.${key}`, `${field}.${key} is not supported`);
142
- }
143
- }
144
-
145
- function validateRenderArgs(args = {}) {
146
- const params = assertObject(args, 'parameters');
147
- assertNoUnknownFields(params, TOP_LEVEL_RENDER_FIELDS, 'parameters');
148
- const mode = normalizeText(params.mode, null);
149
- if (!mode) inputError('mode', 'mode is required');
150
- if (!['stored', 'manual'].includes(mode)) inputError('mode', 'mode must be stored or manual');
151
- const style = normalizeText(params.style, CLAWORLD_TRANSCRIPT_REPORT_STYLE);
152
- if (!CLAWORLD_TRANSCRIPT_REPORT_STYLES.includes(style)) {
153
- inputError('style', `style must be ${CLAWORLD_TRANSCRIPT_REPORT_STYLE}`);
154
- }
155
- const maxPageHeight = Number.isInteger(params.maxPageHeight)
156
- ? params.maxPageHeight
157
- : DEFAULT_MAX_PAGE_HEIGHT;
158
- if (maxPageHeight < MIN_PAGE_HEIGHT || maxPageHeight > MAX_PAGE_HEIGHT) {
159
- inputError('maxPageHeight', `maxPageHeight must be between ${MIN_PAGE_HEIGHT} and ${MAX_PAGE_HEIGHT}`);
160
- }
161
-
162
- if (mode === 'stored') {
163
- const stored = assertObject(params.stored, 'stored');
164
- assertNoUnknownFields(stored, new Set(['chatRequestId']), 'stored');
165
- const chatRequestId = normalizeText(stored.chatRequestId, null);
166
- if (!chatRequestId) inputError('stored.chatRequestId', 'stored.chatRequestId is required');
167
- return { mode, style, maxPageHeight, stored: { chatRequestId } };
168
- }
169
-
170
- const manual = assertObject(params.manual, 'manual');
171
- assertNoUnknownFields(manual, MANUAL_RENDER_FIELDS, 'manual');
172
- for (const field of MANUAL_RENDER_FIELDS) {
173
- if (!Object.prototype.hasOwnProperty.call(manual, field)) {
174
- inputError(`manual.${field}`, `manual.${field} is required`);
175
- }
176
- }
177
- if (!Array.isArray(manual.messages) || manual.messages.length === 0) {
178
- inputError('manual.messages', 'manual.messages must contain at least one message');
179
- }
180
- const messages = manual.messages.map((message, index) => {
181
- if (!isObject(message)) inputError(`manual.messages.${index}`, 'manual message must be an object');
182
- assertNoUnknownFields(message, MANUAL_MESSAGE_FIELDS, `manual.messages.${index}`);
183
- const from = normalizeText(message.from, null);
184
- if (!['peer', 'local'].includes(from)) {
185
- inputError(`manual.messages.${index}.from`, 'manual message from must be peer or local');
186
- }
187
- const text = normalizeText(message.text, null);
188
- if (!text) inputError(`manual.messages.${index}.text`, 'manual message text is required');
189
- const createdAt = normalizeText(message.createdAt, null);
190
- if (!createdAt) inputError(`manual.messages.${index}.createdAt`, 'manual message createdAt is required');
191
- if (!parseTimestampMs(createdAt)) {
192
- inputError(`manual.messages.${index}.createdAt`, 'manual message createdAt must be a parseable timestamp');
193
- }
194
- return { from, text, createdAt };
195
- });
196
- return {
197
- mode,
198
- style,
199
- maxPageHeight,
200
- manual: {
201
- messages,
202
- title: normalizeText(manual.title, null) || inputError('manual.title', 'manual.title is required'),
203
- peerProfile: normalizeText(manual.peerProfile, null) || inputError('manual.peerProfile', 'manual.peerProfile is required'),
204
- localLabel: normalizeText(manual.localLabel, null) || inputError('manual.localLabel', 'manual.localLabel is required'),
205
- peerLabel: normalizeText(manual.peerLabel, null) || inputError('manual.peerLabel', 'manual.peerLabel is required'),
206
- },
207
- };
208
- }
209
-
210
- function resolveWorkspaceRoot(workspaceRoot = null) {
211
- return path.resolve(normalizeText(workspaceRoot, null) || process.cwd());
212
- }
213
-
214
- function stableJson(value) {
215
- return JSON.stringify(value, Object.keys(value || {}).sort());
216
- }
217
-
218
- function sha256Buffer(buffer) {
219
- return crypto.createHash('sha256').update(buffer).digest('hex');
220
- }
221
-
222
- async function hashFile(filePath) {
223
- return sha256Buffer(await fs.readFile(filePath));
224
- }
225
-
226
- function safeFileStem(value) {
227
- const text = normalizeText(value, 'report') || 'report';
228
- return text.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'report';
229
- }
230
-
231
- async function atomicWriteText(filePath, text) {
232
- await fs.mkdir(path.dirname(filePath), { recursive: true });
233
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
234
- await fs.writeFile(tempPath, text, 'utf8');
235
- await fs.rename(tempPath, filePath);
236
- }
237
-
238
- async function atomicWriteBuffer(filePath, buffer) {
239
- await fs.mkdir(path.dirname(filePath), { recursive: true });
240
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
241
- await fs.writeFile(tempPath, buffer);
242
- await fs.rename(tempPath, filePath);
243
- }
244
-
245
- function resolveArtifactDirs(workspaceRoot) {
246
- const baseDir = path.join(
247
- workspaceRoot,
248
- CLAWORLD_WORKING_MEMORY_DIR,
249
- CLAWORLD_REPORTS_DIR,
250
- 'transcripts',
251
- );
252
- return {
253
- baseDir,
254
- imageDir: path.join(baseDir, 'images'),
255
- documentDir: path.join(baseDir, 'documents'),
256
- };
257
- }
258
-
259
- function parseTimestampMs(value) {
260
- if (value == null || value === '') return null;
261
- if (typeof value === 'number' && Number.isFinite(value)) {
262
- return value > 1_000_000_000_000 ? Math.trunc(value) : Math.trunc(value * 1000);
263
- }
264
- const text = String(value).trim();
265
- if (!text) return null;
266
- if (/^-?\d+(\.\d+)?$/.test(text)) return parseTimestampMs(Number(text));
267
- const parsed = Date.parse(text);
268
- return Number.isFinite(parsed) ? parsed : null;
269
- }
270
-
271
- function toIsoTimestamp(value) {
272
- const ms = parseTimestampMs(value);
273
- if (!ms) return null;
274
- return new Date(ms).toISOString();
275
- }
276
-
277
- function formatTimestampLabel(value) {
278
- const ms = parseTimestampMs(value);
279
- if (!ms) return '';
280
- const date = new Date(ms);
281
- const pad = (part) => String(part).padStart(2, '0');
282
- return [
283
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
284
- `${pad(date.getHours())}:${pad(date.getMinutes())}`,
285
- ].join(' ');
286
- }
287
-
288
- function flattenContent(value) {
289
- if (value == null) return '';
290
- if (typeof value === 'string') return value;
291
- if (Array.isArray(value)) {
292
- return value.map(flattenContent).filter(Boolean).join('\n');
293
- }
294
- if (isObject(value)) {
295
- for (const key of ['text', 'input_text', 'output_text', 'content', 'body', 'message']) {
296
- if (value[key] != null) {
297
- const text = flattenContent(value[key]);
298
- if (text) return text;
299
- }
300
- }
301
- return '';
302
- }
303
- return String(value);
304
- }
305
-
306
- function extractFencedBlockAfter(text, marker) {
307
- const source = String(text || '');
308
- const index = source.indexOf(marker);
309
- if (index < 0) return null;
310
- const afterMarker = source.slice(index + marker.length).trimStart();
311
- const fenceMatch = afterMarker.match(/^```[^\n]*\n([\s\S]*?)\n```/);
312
- if (fenceMatch) return fenceMatch[1].trim();
313
- const nextSection = afterMarker.search(/\n(?:Routing metadata|Backend-authored Claworld|Relay untrusted context|Claworld live conversation rules):/);
314
- return (nextSection >= 0 ? afterMarker.slice(0, nextSection) : afterMarker).trim();
315
- }
316
-
317
- function stripInternalEnvelopeSections(text) {
318
- let output = String(text || '');
319
- const markers = [
320
- 'Routing metadata:',
321
- 'Backend-authored Claworld context:',
322
- 'Backend-authored Claworld command:',
323
- 'Relay untrusted context:',
324
- 'Claworld live conversation rules:',
325
- ];
326
- for (const marker of markers) {
327
- const index = output.indexOf(marker);
328
- if (index >= 0) output = output.slice(0, index);
329
- }
330
- output = output.replace(/```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```/g, '$1');
331
- return output.trim();
332
- }
333
-
334
- function stripOpenClawSpeakerPrefix(text) {
335
- const source = String(text || '').trim();
336
- const match = source.match(/^([^\n:]{1,80}):\s+([\s\S]+)$/);
337
- if (!match) return { text: source, speaker: null };
338
- const label = match[1].trim();
339
- if (/^(https?|file|data)$/i.test(label)) return { text: source, speaker: null };
340
- return { text: match[2].trim(), speaker: label };
341
- }
342
-
343
- function redactSensitiveText(text) {
344
- let output = String(text || '');
345
- output = output.replace(
346
- /\bauthorization\b\s*[:=]\s*(?:bearer\s+)?`?[^`\s,;]+`?/gi,
347
- 'authorization=[REDACTED]',
348
- );
349
- output = output.replace(
350
- /\b(api[_-]?key|app[_-]?token|access[_-]?token|refresh[_-]?token|secret|password)\b\s*[:=]\s*`?[^`\s,;]+`?/gi,
351
- '$1=[REDACTED]',
352
- );
353
- output = output.replace(/\bbearer\s+[a-zA-Z0-9._~+/=-]{12,}/gi, 'bearer [REDACTED]');
354
- output = output.replace(/\bsk-[a-zA-Z0-9_-]{12,}\b/g, '[REDACTED_TOKEN]');
355
- output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED_EMAIL]');
356
- output = output.replace(/\+?\d[\d ()-]{8,}\d/g, '[REDACTED_PHONE]');
357
- return output.trim();
358
- }
359
-
360
- function normalizeTagLabel(value) {
361
- const tag = normalizeText(value, null);
362
- if (!tag) return null;
363
- const compact = tag.toLowerCase().replace(/\s+/g, '_');
364
- if (compact === 'request_conversation_end' || compact === 'request_end' || compact === 'end') {
365
- return 'request end';
366
- }
367
- if (compact === 'like') return 'like';
368
- if (compact === 'dislike') return 'dislike';
369
- return tag.replace(/_/g, ' ').trim().toLowerCase();
370
- }
371
-
372
- function extractControlTags(text) {
373
- const source = String(text || '');
374
- const matches = [];
375
- const claimedSpans = [];
376
- const tags = [];
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);
404
- return {
405
- text: cleaned.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(),
406
- tags,
407
- };
408
- }
409
-
410
- function extractEpisodeIds(...values) {
411
- const ids = new Set();
412
- const source = values.map((value) => String(value || '')).join('\n');
413
- const patterns = [
414
- /(?:Chat Request ID|chatRequestId|requestId|Intent ID|intentId)\s*[:=]\s*`?([a-zA-Z0-9._:-]+)/gi,
415
- /\b(?:chatRequestId|requestId|intentId)\b["']?\s*[:=]\s*["']?([a-zA-Z0-9._:-]+)/gi,
416
- ];
417
- for (const pattern of patterns) {
418
- for (const match of source.matchAll(pattern)) {
419
- const id = normalizeText(match[1], null);
420
- if (id) ids.add(id.replace(/[`,.;)\]}]+$/g, ''));
421
- }
422
- }
423
- return [...ids];
424
- }
425
-
426
- function getMessageText(record) {
427
- if (record?.text != null) return flattenContent(record.text);
428
- if (record?.body != null) return flattenContent(record.body);
429
- if (record?.content != null) return flattenContent(record.content);
430
- if (record?.message?.content != null) return flattenContent(record.message.content);
431
- if (record?.payload?.content != null) return flattenContent(record.payload.content);
432
- return '';
433
- }
434
-
435
- function getMessageRole(record) {
436
- return normalizeText(
437
- record?.role
438
- ?? record?.from
439
- ?? record?.message?.role
440
- ?? record?.payload?.role,
441
- null,
442
- );
443
- }
444
-
445
- function getMessageTimestamp(record) {
446
- return record?.createdAt
447
- ?? record?.created_at
448
- ?? record?.timestamp
449
- ?? record?.message?.createdAt
450
- ?? record?.message?.timestamp
451
- ?? record?.payload?.createdAt
452
- ?? record?.payload?.timestamp
453
- ?? record?.__openclaw?.recordTimestampMs
454
- ?? record?.__openclaw?.recordTimestamp
455
- ?? null;
456
- }
457
-
458
- function normalizeManualMessage(raw, index) {
459
- const tagsResult = extractControlTags(raw.text);
460
- return {
461
- id: `manual-${index + 1}`,
462
- side: raw.from === 'peer' ? 'peer' : 'local',
463
- speaker: null,
464
- text: redactSensitiveText(tagsResult.text),
465
- tags: tagsResult.tags,
466
- createdAt: toIsoTimestamp(raw.createdAt),
467
- createdAtLabel: formatTimestampLabel(raw.createdAt),
468
- timestampMs: parseTimestampMs(raw.createdAt),
469
- episodeIds: [],
470
- sourceRole: raw.from,
471
- };
472
- }
473
-
474
- function normalizeStoredMessage(record, index) {
475
- const role = getMessageRole(record);
476
- if (['system', 'developer', 'tool', 'tool_call'].includes(role)) return null;
477
-
478
- const side = role === 'assistant' || role === 'local' || role === 'me' || role === 'right'
479
- ? 'local'
480
- : 'peer';
481
- let text = getMessageText(record);
482
- const rawText = text;
483
- const episodeIds = extractEpisodeIds(
484
- rawText,
485
- record?.id,
486
- record?.message?.id,
487
- record?.__openclaw?.id,
488
- );
489
-
490
- if (side === 'peer') {
491
- const peerVisible = extractFencedBlockAfter(text, 'Peer-visible Claworld message:');
492
- const hasBackendCommand = text.includes('Backend-authored Claworld command:');
493
- if (peerVisible != null) {
494
- text = peerVisible;
495
- } else if (hasBackendCommand) {
496
- return null;
497
- } else {
498
- text = stripInternalEnvelopeSections(text);
499
- }
500
- } else {
501
- text = stripInternalEnvelopeSections(text);
502
- if (/^NO_REPLY$/i.test(text.trim())) return null;
503
- }
504
-
505
- const speakerResult = side === 'peer' ? stripOpenClawSpeakerPrefix(text) : { text: text.trim(), speaker: null };
506
- const tagsResult = extractControlTags(speakerResult.text);
507
- const redactedText = redactSensitiveText(tagsResult.text);
508
- if (!redactedText && tagsResult.tags.length === 0) return null;
509
- const timestamp = getMessageTimestamp(record) || new Date(0).toISOString();
510
- return {
511
- id: normalizeText(record?.id ?? record?.message?.id ?? record?.__openclaw?.id, `message-${index + 1}`),
512
- side,
513
- speaker: speakerResult.speaker,
514
- text: redactedText,
515
- tags: tagsResult.tags,
516
- createdAt: toIsoTimestamp(timestamp),
517
- createdAtLabel: formatTimestampLabel(timestamp),
518
- timestampMs: parseTimestampMs(timestamp) || 0,
519
- episodeIds,
520
- sourceRole: role,
521
- };
522
- }
523
-
524
- function normalizeTranscriptMessages(records, { mode } = {}) {
525
- const messages = records
526
- .map((record, index) => (
527
- mode === 'manual'
528
- ? normalizeManualMessage(record, index)
529
- : normalizeStoredMessage(record, index)
530
- ))
531
- .filter(Boolean)
532
- .filter((message) => message.text || message.tags.length > 0)
533
- .sort((left, right) => (left.timestampMs || 0) - (right.timestampMs || 0));
534
- return messages;
535
- }
536
-
537
- function episodeSetsIntersect(left = [], right = []) {
538
- if (!left.length || !right.length) return true;
539
- const rightSet = new Set(right);
540
- return left.some((id) => rightSet.has(id));
541
- }
542
-
543
- function segmentTranscriptMessages(messages = []) {
544
- const segments = [];
545
- let current = null;
546
- for (const message of messages) {
547
- const previous = current?.messages?.[current.messages.length - 1] || null;
548
- const gapSeconds = previous && message.timestampMs && previous.timestampMs
549
- ? Math.abs(message.timestampMs - previous.timestampMs) / 1000
550
- : 0;
551
- const currentEpisodeIds = current ? [...current.episodeIds] : [];
552
- const startsNewEpisode = current
553
- && message.episodeIds.length > 0
554
- && currentEpisodeIds.length > 0
555
- && !episodeSetsIntersect(message.episodeIds, currentEpisodeIds);
556
- const startsAfterCompletion = previous
557
- && current?.endedSides?.size >= 2
558
- && message.side === 'peer';
559
- if (!current || gapSeconds > SEGMENT_GAP_SECONDS || startsNewEpisode || startsAfterCompletion) {
560
- current = {
561
- messages: [],
562
- episodeIds: new Set(),
563
- endedSides: new Set(),
564
- };
565
- segments.push(current);
566
- }
567
- current.messages.push(message);
568
- for (const id of message.episodeIds) current.episodeIds.add(id);
569
- if (message.tags.includes('request end')) current.endedSides.add(message.side);
570
- }
571
- return segments;
572
- }
573
-
574
- function selectStoredMessages(messages, chatRequestId) {
575
- const targetId = normalizeText(chatRequestId, null);
576
- if (!targetId) return messages;
577
- const segments = segmentTranscriptMessages(messages);
578
- const segment = segments.find((candidate) => candidate.episodeIds.has(targetId));
579
- if (segment) return segment.messages;
580
- const tagged = messages.filter((message) => message.episodeIds.includes(targetId));
581
- return tagged.length > 0 ? tagged : messages;
582
- }
583
-
584
- function parseJsonLine(line) {
585
- try {
586
- const parsed = JSON.parse(line);
587
- return isObject(parsed) ? parsed : null;
588
- } catch {
589
- return null;
590
- }
591
- }
592
-
593
- function extractTranscriptRecordsFromJson(value, fallbackId = 'json') {
594
- if (Array.isArray(value)) return value;
595
- if (!isObject(value)) return [];
596
- for (const key of ['entries', 'records', 'messages', 'items', 'transcript']) {
597
- if (Array.isArray(value[key])) return value[key];
598
- }
599
- if (Array.isArray(value.conversation?.messages)) return value.conversation.messages;
600
- if (Array.isArray(value.session?.messages)) return value.session.messages;
601
- if (isObject(value.message) || value.role || value.content || value.text) {
602
- return [{ ...value, id: value.id ?? fallbackId }];
603
- }
604
- return [];
605
- }
606
-
607
- async function loadTranscriptRecords(transcriptPath) {
608
- const content = await fs.readFile(transcriptPath, 'utf8');
609
- const trimmed = content.trim();
610
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
611
- try {
612
- return extractTranscriptRecordsFromJson(JSON.parse(trimmed), path.basename(transcriptPath));
613
- } catch {
614
- // Fall back to line-oriented parsing below.
615
- }
616
- }
617
- const records = [];
618
- let index = 0;
619
- for (const line of content.split(/\r?\n/)) {
620
- if (!line.trim()) continue;
621
- index += 1;
622
- const parsed = parseJsonLine(line);
623
- if (!parsed) continue;
624
- if (isObject(parsed.message)) {
625
- records.push({
626
- ...parsed.message,
627
- id: parsed.message.id ?? parsed.id ?? `line-${index}`,
628
- timestamp: parsed.message.timestamp ?? parsed.timestamp ?? parsed.createdAt ?? null,
629
- __openclaw: {
630
- id: parsed.id ?? null,
631
- recordTimestamp: parsed.timestamp ?? null,
632
- seq: index,
633
- },
634
- });
635
- continue;
636
- }
637
- if (parsed.type === 'response_item' && isObject(parsed.payload) && parsed.payload.type === 'message') {
638
- records.push({
639
- ...parsed.payload,
640
- id: parsed.payload.id ?? parsed.id ?? `line-${index}`,
641
- timestamp: parsed.payload.timestamp ?? parsed.timestamp ?? null,
642
- __openclaw: {
643
- id: parsed.id ?? null,
644
- recordTimestamp: parsed.timestamp ?? null,
645
- seq: index,
646
- },
647
- });
648
- continue;
649
- }
650
- if (parsed.type === 'message' || parsed.role || parsed.content || parsed.text) {
651
- records.push({
652
- ...parsed,
653
- id: parsed.id ?? `line-${index}`,
654
- timestamp: parsed.timestamp ?? parsed.createdAt ?? null,
655
- __openclaw: {
656
- id: parsed.id ?? null,
657
- recordTimestamp: parsed.timestamp ?? null,
658
- seq: index,
659
- },
660
- });
661
- }
662
- }
663
- return records;
664
- }
665
-
666
- async function pathExists(filePath) {
667
- try {
668
- await fs.access(filePath);
669
- return true;
670
- } catch {
671
- return false;
672
- }
673
- }
674
-
675
- function artifactPathCandidates(artifact = {}) {
676
- return [
677
- normalizeText(artifact.transcriptPath, null),
678
- normalizeText(artifact.sessionFile, null),
679
- ].filter(Boolean);
680
- }
681
-
682
- async function chooseReadableArtifact(artifacts = []) {
683
- const ordered = [...artifacts].reverse();
684
- for (const artifact of ordered) {
685
- for (const candidatePath of artifactPathCandidates(artifact)) {
686
- if (await pathExists(candidatePath)) {
687
- return {
688
- artifact,
689
- transcriptPath: candidatePath,
690
- };
691
- }
692
- }
693
- }
694
- const fallback = ordered.find((artifact) => artifactPathCandidates(artifact).length > 0);
695
- if (!fallback) return null;
696
- return {
697
- artifact: fallback,
698
- transcriptPath: artifactPathCandidates(fallback)[0],
699
- };
700
- }
701
-
702
- function summarizeDirectoryRequest({
703
- chatRequestId,
704
- localSessionKey,
705
- session = {},
706
- request = {},
707
- artifact = {},
708
- transcriptPath = null,
709
- } = {}) {
710
- return {
711
- chatRequestId,
712
- localSessionKey,
713
- relaySessionKey: normalizeText(session.relaySessionKey, null),
714
- conversationKey: normalizeText(session.conversationKey, null),
715
- worldId: normalizeText(session.worldId, null),
716
- localAgentId: normalizeText(session.localAgentId, null),
717
- firstSeenAt: normalizeText(request.firstSeenAt, null),
718
- lastSeenAt: normalizeText(request.lastSeenAt, null),
719
- artifactCount: Array.isArray(request.artifacts) ? request.artifacts.length : 0,
720
- sessionId: normalizeText(artifact.sessionId, null),
721
- sessionFile: normalizeText(artifact.sessionFile, null),
722
- transcriptPath: normalizeText(transcriptPath || artifact.transcriptPath, null),
723
- deliveryId: normalizeText(artifact.deliveryId, null),
724
- sourceEventId: normalizeText(artifact.sourceEventId, null),
725
- };
726
- }
727
-
728
- async function resolveStoredTranscriptSource(workspaceRoot, chatRequestId) {
729
- const sessionDirectory = await readClaworldSessionDirectory(workspaceRoot);
730
- const conversationSessions = sessionDirectory.directory?.conversationSessions || {};
731
- for (const [localSessionKey, session] of Object.entries(conversationSessions)) {
732
- const request = session?.chatRequests?.[chatRequestId];
733
- if (!request) continue;
734
- const artifacts = Array.isArray(request.artifacts) ? request.artifacts : [];
735
- const readable = await chooseReadableArtifact(artifacts);
736
- if (!readable?.transcriptPath) {
737
- inputError('stored.chatRequestId', `No local transcript artifact is indexed for chatRequestId=${chatRequestId}`);
738
- }
739
- return {
740
- mode: 'stored',
741
- chatRequestId,
742
- transcriptPath: readable.transcriptPath,
743
- sessionDirectoryPath: sessionDirectory.sessionDirectoryPath,
744
- summary: summarizeDirectoryRequest({
745
- chatRequestId,
746
- localSessionKey,
747
- session,
748
- request,
749
- artifact: readable.artifact,
750
- transcriptPath: readable.transcriptPath,
751
- }),
752
- };
753
- }
754
- inputError('stored.chatRequestId', `chatRequestId=${chatRequestId} was not found in .claworld/sessions/index.json`);
755
- }
756
-
757
- function normalizeHeaderContext({ mode, manual = null, source = null, messages = [] } = {}) {
758
- if (mode === 'manual') {
759
- return {
760
- title: manual.title,
761
- subtitle: manual.peerProfile,
762
- peerLabel: manual.peerLabel,
763
- localLabel: manual.localLabel,
764
- };
765
- }
766
- const firstPeer = messages.find((message) => message.side === 'peer');
767
- const peerLabel = firstPeer?.speaker
768
- || normalizeText(source?.summary?.counterpartyAgentId, null)
769
- || normalizeText(source?.summary?.relaySessionKey, null)
770
- || 'Peer';
771
- const title = normalizeText(firstPeer?.speaker, null)
772
- || normalizeText(source?.summary?.conversationKey, null)
773
- || normalizeText(source?.chatRequestId, null)
774
- || 'Claworld conversation';
775
- const subtitleParts = [
776
- normalizeText(source?.summary?.worldId, null) ? `world ${source.summary.worldId}` : null,
777
- normalizeText(source?.chatRequestId, null) ? `chat request ${source.chatRequestId}` : null,
778
- ].filter(Boolean);
779
- return {
780
- title,
781
- subtitle: subtitleParts.join(' · ') || 'Claworld transcript',
782
- peerLabel,
783
- localLabel: 'You',
784
- };
785
- }
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
-
810
- function displayColumns(text) {
811
- let columns = 0;
812
- for (const char of String(text || '')) columns += charColumns(char);
813
- return columns;
814
- }
815
-
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) {
884
- const lines = [];
885
- const paragraphs = String(text || '').replace(/\r/g, '').split('\n');
886
- for (const paragraph of paragraphs.length ? paragraphs : ['']) {
887
- let current = '';
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) {
900
- lines.push(current.trimEnd());
901
- paragraphHasLine = true;
902
- current = '';
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;
920
- }
921
- }
922
- if (current || !paragraphHasLine) lines.push(current.trimEnd());
923
- }
924
- return lines.length > 0 ? lines : [''];
925
- }
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
-
942
- function timeLabelForMessage(message) {
943
- return formatCompactTimestamp(message.createdAt || message.timestampMs || message.createdAtLabel)
944
- || message.createdAtLabel
945
- || '';
946
- }
947
-
948
- function buildRenderItems(messages = []) {
949
- const items = [];
950
- let previousMessage = null;
951
- for (const message of messages) {
952
- const gapSeconds = previousMessage && message.timestampMs && previousMessage.timestampMs
953
- ? Math.abs(message.timestampMs - previousMessage.timestampMs) / 1000
954
- : Number.POSITIVE_INFINITY;
955
- if (!previousMessage || gapSeconds > TIME_SPLIT_SECONDS) {
956
- items.push({
957
- kind: 'time',
958
- label: timeLabelForMessage(message),
959
- });
960
- }
961
- items.push({
962
- kind: 'message',
963
- message,
964
- });
965
- previousMessage = message;
966
- }
967
- return items;
968
- }
969
-
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,
1019
- );
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;
1028
- return {
1029
- kind: 'message',
1030
- message: item.message,
1031
- side,
1032
- participantLabel: label,
1033
- lines,
1034
- width: bubbleWidth,
1035
- height: rowHeight,
1036
- tagHeight,
1037
- textHeight,
1038
- };
1039
- }
1040
-
1041
- function layoutPages({ messages, header, maxPageHeight }) {
1042
- const width = DEFAULT_WIDTH;
1043
- const maxHeight = maxPageHeight || DEFAULT_MAX_PAGE_HEIGHT;
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
- });
1063
-
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,
1117
- width,
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',
1123
- };
1124
- });
1125
-
1126
- return {
1127
- width,
1128
- header,
1129
- pages,
1130
- };
1131
- }
1132
-
1133
- function escapeXml(value) {
1134
- return String(value ?? '')
1135
- .replace(/&/g, '&amp;')
1136
- .replace(/</g, '&lt;')
1137
- .replace(/>/g, '&gt;')
1138
- .replace(/"/g, '&quot;');
1139
- }
1140
-
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.`;
1145
- const parts = [
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">',
1156
- ];
1157
- for (const item of page.elements) {
1158
- if (item.kind === 'ellipsis') {
1159
- parts.push(renderEllipsisSvg(page, item));
1160
- continue;
1161
- }
1162
- if (item.kind === 'time') {
1163
- parts.push(renderTimeSvg(page, item));
1164
- continue;
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>`);
1171
- }
1172
- parts.push('</svg>');
1173
- return parts.join('\n');
1174
- }
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
-
1449
- function buildBubbleSpec({ mode, source, header, messages, layout, style }) {
1450
- return {
1451
- schema: ARTIFACT_SCHEMA,
1452
- format: 'bubblespec',
1453
- style,
1454
- width: layout.width,
1455
- mode,
1456
- chatRequestId: source?.chatRequestId || null,
1457
- source: {
1458
- mode,
1459
- transcriptPath: source?.transcriptPath || null,
1460
- sessionDirectoryPath: source?.sessionDirectoryPath || null,
1461
- summary: source?.summary || null,
1462
- },
1463
- header,
1464
- messages: messages.map((message) => ({
1465
- id: message.id,
1466
- side: message.side,
1467
- speaker: message.speaker,
1468
- text: message.text,
1469
- tags: message.tags,
1470
- createdAt: message.createdAt,
1471
- episodeIds: message.episodeIds,
1472
- })),
1473
- pages: layout.pages.map((page) => ({
1474
- page: page.page,
1475
- width: page.width,
1476
- height: page.height,
1477
- elementCount: page.elements.length,
1478
- })),
1479
- };
1480
- }
1481
-
1482
- function makeCrcTable() {
1483
- const table = [];
1484
- for (let n = 0; n < 256; n += 1) {
1485
- let c = n;
1486
- for (let k = 0; k < 8; k += 1) {
1487
- c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
1488
- }
1489
- table[n] = c >>> 0;
1490
- }
1491
- return table;
1492
- }
1493
-
1494
- const CRC_TABLE = makeCrcTable();
1495
-
1496
- function crc32(buffer) {
1497
- let crc = 0xffffffff;
1498
- for (const byte of buffer) {
1499
- crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
1500
- }
1501
- return (crc ^ 0xffffffff) >>> 0;
1502
- }
1503
-
1504
- function pngChunk(type, data = Buffer.alloc(0)) {
1505
- const typeBuffer = Buffer.from(type, 'ascii');
1506
- const length = Buffer.alloc(4);
1507
- length.writeUInt32BE(data.length, 0);
1508
- const crcBuffer = Buffer.alloc(4);
1509
- crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
1510
- return Buffer.concat([length, typeBuffer, data, crcBuffer]);
1511
- }
1512
-
1513
- function writeRect(rgba, width, x, y, rectWidth, rectHeight, color) {
1514
- const [r, g, b, a] = color;
1515
- const left = Math.max(0, Math.floor(x));
1516
- const top = Math.max(0, Math.floor(y));
1517
- const right = Math.min(width, Math.ceil(x + rectWidth));
1518
- const bottom = Math.min(Math.floor(rgba.length / (width * 4)), Math.ceil(y + rectHeight));
1519
- for (let row = top; row < bottom; row += 1) {
1520
- for (let col = left; col < right; col += 1) {
1521
- const offset = (row * width + col) * 4;
1522
- rgba[offset] = r;
1523
- rgba[offset + 1] = g;
1524
- rgba[offset + 2] = b;
1525
- rgba[offset + 3] = a;
1526
- }
1527
- }
1528
- }
1529
-
1530
- function buildFallbackPngBuffer(page) {
1531
- const width = page.width;
1532
- const height = page.height;
1533
- const rgba = Buffer.alloc(width * height * 4, 255);
1534
- writeRect(rgba, width, 0, 0, width, height, [251, 247, 239, 255]);
1535
- writeRect(rgba, width, 22, 18, width - 44, height - 36, [255, 253, 248, 255]);
1536
- writeRect(rgba, width, 40, 38, width - 80, 18, [22, 22, 22, 255]);
1537
- writeRect(rgba, width, 40, 68, width - 180, 10, [120, 112, 100, 255]);
1538
- for (const element of page.elements) {
1539
- if (element.kind !== 'message') continue;
1540
- const color = element.message.side === 'local' ? [223, 243, 229, 255] : [255, 255, 255, 255];
1541
- writeRect(rgba, width, element.x, element.y + 14, element.width, element.height, color);
1542
- writeRect(rgba, width, element.x + 16, element.y + 36, element.width - 32, 5, [45, 45, 45, 255]);
1543
- writeRect(rgba, width, element.x + 16, element.y + 54, Math.max(60, element.width - 90), 5, [80, 80, 80, 255]);
1544
- }
1545
- const raw = Buffer.alloc((width * 4 + 1) * height);
1546
- for (let row = 0; row < height; row += 1) {
1547
- const rawOffset = row * (width * 4 + 1);
1548
- raw[rawOffset] = 0;
1549
- rgba.copy(raw, rawOffset + 1, row * width * 4, (row + 1) * width * 4);
1550
- }
1551
- const ihdr = Buffer.alloc(13);
1552
- ihdr.writeUInt32BE(width, 0);
1553
- ihdr.writeUInt32BE(height, 4);
1554
- ihdr[8] = 8;
1555
- ihdr[9] = 6;
1556
- ihdr[10] = 0;
1557
- ihdr[11] = 0;
1558
- ihdr[12] = 0;
1559
- return Buffer.concat([
1560
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
1561
- pngChunk('IHDR', ihdr),
1562
- pngChunk('IDAT', deflateSync(raw)),
1563
- pngChunk('IEND'),
1564
- ]);
1565
- }
1566
-
1567
- async function tryConvertSvgToPng(svgPath, pngPath, page) {
1568
- const attempts = [
1569
- { renderer: 'rsvg-convert', cmd: 'rsvg-convert', args: ['--format', 'png', '--output', pngPath, svgPath] },
1570
- { renderer: 'resvg', cmd: 'resvg', args: [svgPath, pngPath] },
1571
- { renderer: 'magick', cmd: 'magick', args: [svgPath, pngPath] },
1572
- { renderer: 'convert', cmd: 'convert', args: [svgPath, pngPath] },
1573
- { renderer: 'sips', cmd: 'sips', args: ['-s', 'format', 'png', svgPath, '--out', pngPath] },
1574
- ];
1575
- for (const attempt of attempts) {
1576
- try {
1577
- await execFileAsync(attempt.cmd, attempt.args, { timeout: 15000, maxBuffer: 2 * 1024 * 1024 });
1578
- const stat = await fs.stat(pngPath);
1579
- if (stat.size > 64) return attempt.renderer;
1580
- } catch {
1581
- // Continue to the next local renderer.
1582
- }
1583
- }
1584
- await atomicWriteBuffer(pngPath, buildFallbackPngBuffer(page));
1585
- return 'fallback-png';
1586
- }
1587
-
1588
- async function writeArtifacts({ workspaceRoot, artifactId, spec, layout }) {
1589
- const dirs = resolveArtifactDirs(workspaceRoot);
1590
- await fs.mkdir(dirs.imageDir, { recursive: true });
1591
- await fs.mkdir(dirs.documentDir, { recursive: true });
1592
- const bubbleSpecPath = path.join(dirs.documentDir, `${artifactId}.bubblespec.json`);
1593
- await atomicWriteText(bubbleSpecPath, `${JSON.stringify(spec, null, 2)}\n`);
1594
- const svgPages = [];
1595
- const pngPages = [];
1596
- for (const page of layout.pages) {
1597
- const pageStem = `${artifactId}-page-${String(page.page).padStart(2, '0')}`;
1598
- const svgPath = path.join(dirs.documentDir, `${pageStem}.svg`);
1599
- const pngPath = path.join(dirs.imageDir, `${pageStem}.png`);
1600
- await atomicWriteText(svgPath, renderSvgPage({ page, header: layout.header, pageCount: layout.pages.length }));
1601
- const renderer = await tryConvertSvgToPng(svgPath, pngPath, page);
1602
- svgPages.push({
1603
- page: page.page,
1604
- format: 'svg',
1605
- path: svgPath,
1606
- width: page.width,
1607
- height: page.height,
1608
- sha256: await hashFile(svgPath),
1609
- });
1610
- pngPages.push({
1611
- page: page.page,
1612
- format: 'png',
1613
- path: pngPath,
1614
- filePath: pngPath,
1615
- filename: path.basename(pngPath),
1616
- mimeType: 'image/png',
1617
- width: page.width,
1618
- height: page.height,
1619
- renderer,
1620
- sha256: await hashFile(pngPath),
1621
- });
1622
- }
1623
- return {
1624
- bubbleSpec: {
1625
- format: 'bubblespec',
1626
- path: bubbleSpecPath,
1627
- sha256: await hashFile(bubbleSpecPath),
1628
- },
1629
- svgPages,
1630
- pngPages,
1631
- };
1632
- }
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
-
1647
- function buildDiagnostics({ sourceRecords, normalizedMessages, renderedMessages, source }) {
1648
- return {
1649
- source: {
1650
- mode: source.mode,
1651
- transcriptPath: source.transcriptPath || null,
1652
- sessionDirectoryPath: source.sessionDirectoryPath || null,
1653
- },
1654
- stats: {
1655
- sourceMessages: sourceRecords.length,
1656
- normalizedMessages: normalizedMessages.length,
1657
- renderedMessages: renderedMessages.length,
1658
- droppedMessages: Math.max(0, sourceRecords.length - normalizedMessages.length),
1659
- },
1660
- };
1661
- }
1662
-
1663
- async function appendRenderJournalEvent(workspaceRoot, result) {
1664
- try {
1665
- await appendClaworldJournalEvent(workspaceRoot, {
1666
- source: 'claworld_runtime',
1667
- kind: 'transcript_report',
1668
- scope: 'conversation',
1669
- summary: `Rendered Claworld transcript report ${result.artifactId}.`,
1670
- refs: {
1671
- chatRequestId: result.chatRequestId || null,
1672
- artifactId: result.artifactId,
1673
- },
1674
- artifacts: {
1675
- bubbleSpec: result.artifacts?.bubbleSpec?.path || null,
1676
- pngPages: (result.artifacts?.pngPages || []).map((page) => page.path),
1677
- svgPages: (result.artifacts?.svgPages || []).map((page) => page.path),
1678
- },
1679
- }, { updateSessionDirectory: false });
1680
- } catch {
1681
- // Rendering should not fail because the journal is unavailable.
1682
- }
1683
- }
1684
-
1685
- export async function renderClaworldTranscriptReport({
1686
- workspaceRoot = null,
1687
- agentId = null,
1688
- args = {},
1689
- } = {}) {
1690
- const resolvedWorkspaceRoot = resolveWorkspaceRoot(workspaceRoot);
1691
- await ensureClaworldWorkingMemory(resolvedWorkspaceRoot);
1692
- const params = validateRenderArgs(args);
1693
- let source = { mode: params.mode };
1694
- let sourceRecords = [];
1695
- let normalizedMessages = [];
1696
- let renderedMessages = [];
1697
- if (params.mode === 'manual') {
1698
- sourceRecords = params.manual.messages;
1699
- normalizedMessages = normalizeTranscriptMessages(sourceRecords, { mode: 'manual' });
1700
- renderedMessages = normalizedMessages;
1701
- source = {
1702
- mode: 'manual',
1703
- summary: {
1704
- suppliedMessageCount: sourceRecords.length,
1705
- },
1706
- };
1707
- } else {
1708
- source = await resolveStoredTranscriptSource(resolvedWorkspaceRoot, params.stored.chatRequestId);
1709
- sourceRecords = await loadTranscriptRecords(source.transcriptPath);
1710
- normalizedMessages = normalizeTranscriptMessages(sourceRecords, { mode: 'stored' });
1711
- renderedMessages = selectStoredMessages(normalizedMessages, params.stored.chatRequestId);
1712
- }
1713
- if (renderedMessages.length === 0) {
1714
- inputError('messages', 'No renderable peer-visible transcript messages were found');
1715
- }
1716
-
1717
- const header = normalizeHeaderContext({
1718
- mode: params.mode,
1719
- manual: params.manual || null,
1720
- source,
1721
- messages: renderedMessages,
1722
- });
1723
- const layout = layoutPages({
1724
- messages: renderedMessages,
1725
- header,
1726
- maxPageHeight: params.maxPageHeight,
1727
- });
1728
- const spec = buildBubbleSpec({
1729
- mode: params.mode,
1730
- source,
1731
- header,
1732
- messages: renderedMessages,
1733
- layout,
1734
- style: params.style,
1735
- });
1736
- const seed = `${params.mode}:${source?.chatRequestId || header.title}:${renderedMessages.length}:${Date.now()}:${stableJson({ agentId })}`;
1737
- const artifactId = `claworld-transcript-${safeFileStem(source?.chatRequestId || header.title)}-${sha256Buffer(Buffer.from(seed)).slice(0, 10)}`;
1738
- const artifacts = await writeArtifacts({
1739
- workspaceRoot: resolvedWorkspaceRoot,
1740
- artifactId,
1741
- spec,
1742
- layout,
1743
- });
1744
- const result = {
1745
- status: 'ok',
1746
- mode: params.mode,
1747
- chatRequestId: source.chatRequestId || null,
1748
- artifactId,
1749
- messageCount: renderedMessages.length,
1750
- pageCount: layout.pages.length,
1751
- style: params.style,
1752
- artifacts,
1753
- deliveryHint: {
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),
1764
- },
1765
- diagnostics: buildDiagnostics({
1766
- sourceRecords,
1767
- normalizedMessages,
1768
- renderedMessages,
1769
- source,
1770
- }),
1771
- };
1772
- await appendRenderJournalEvent(resolvedWorkspaceRoot, result);
1773
- return result;
1774
- }
1775
-
1776
- function buildEpisodeFilters(input = {}) {
1777
- const source = isObject(input.filters) ? input.filters : input;
1778
- return {
1779
- chatRequestId: normalizeText(source.chatRequestId, null),
1780
- conversationKey: normalizeText(source.conversationKey, null),
1781
- localSessionKey: normalizeText(source.localSessionKey, null),
1782
- worldId: normalizeText(source.worldId, null),
1783
- counterpartyAgentId: normalizeText(source.counterpartyAgentId, null),
1784
- };
1785
- }
1786
-
1787
- function episodeMatchesFilters(summary, filters) {
1788
- if (filters.chatRequestId && summary.chatRequestId !== filters.chatRequestId) return false;
1789
- if (filters.localSessionKey && summary.localSessionKey !== filters.localSessionKey) return false;
1790
- if (filters.conversationKey && summary.conversationKey !== filters.conversationKey) return false;
1791
- if (filters.worldId && summary.worldId !== filters.worldId) return false;
1792
- if (
1793
- filters.counterpartyAgentId
1794
- && summary.relaySessionKey !== filters.counterpartyAgentId
1795
- && summary.conversationKey?.includes(filters.counterpartyAgentId) !== true
1796
- ) {
1797
- return false;
1798
- }
1799
- return true;
1800
- }
1801
-
1802
- export async function listClaworldLocalTranscriptEpisodes({
1803
- workspaceRoot = null,
1804
- filters = {},
1805
- } = {}) {
1806
- const resolvedWorkspaceRoot = resolveWorkspaceRoot(workspaceRoot);
1807
- const normalizedFilters = buildEpisodeFilters(filters);
1808
- const sessionDirectory = await readClaworldSessionDirectory(resolvedWorkspaceRoot);
1809
- const summaries = [];
1810
- const conversationSessions = sessionDirectory.directory?.conversationSessions || {};
1811
- for (const [localSessionKey, session] of Object.entries(conversationSessions)) {
1812
- const chatRequests = session?.chatRequests || {};
1813
- for (const [chatRequestId, request] of Object.entries(chatRequests)) {
1814
- const artifacts = Array.isArray(request?.artifacts) ? request.artifacts : [];
1815
- const latestArtifact = [...artifacts].reverse().find((artifact) => artifactPathCandidates(artifact).length > 0) || {};
1816
- const summary = summarizeDirectoryRequest({
1817
- chatRequestId,
1818
- localSessionKey,
1819
- session,
1820
- request,
1821
- artifact: latestArtifact,
1822
- transcriptPath: latestArtifact.transcriptPath || latestArtifact.sessionFile || null,
1823
- });
1824
- if (episodeMatchesFilters(summary, normalizedFilters)) summaries.push(summary);
1825
- }
1826
- }
1827
- return summaries.sort((left, right) => String(right.lastSeenAt || '').localeCompare(String(left.lastSeenAt || '')));
1828
- }
1829
-
1830
- export async function summarizeClaworldChatRequestTranscript({
1831
- workspaceRoot = null,
1832
- chatRequestId,
1833
- } = {}) {
1834
- const resolvedWorkspaceRoot = resolveWorkspaceRoot(workspaceRoot);
1835
- const id = normalizeText(chatRequestId, null);
1836
- if (!id) inputError('chatRequestId', 'chatRequestId is required');
1837
- const source = await resolveStoredTranscriptSource(resolvedWorkspaceRoot, id);
1838
- const sourceRecords = await loadTranscriptRecords(source.transcriptPath);
1839
- const normalizedMessages = normalizeTranscriptMessages(sourceRecords, { mode: 'stored' });
1840
- const renderedMessages = selectStoredMessages(normalizedMessages, id);
1841
- return {
1842
- chatRequestId: id,
1843
- transcriptPath: source.transcriptPath,
1844
- sourceMessageCount: sourceRecords.length,
1845
- renderableMessageCount: renderedMessages.length,
1846
- peerMessageCount: renderedMessages.filter((message) => message.side === 'peer').length,
1847
- localMessageCount: renderedMessages.filter((message) => message.side === 'local').length,
1848
- firstMessageAt: renderedMessages[0]?.createdAt || null,
1849
- lastMessageAt: renderedMessages[renderedMessages.length - 1]?.createdAt || null,
1850
- };
1851
- }
1852
-
1853
- function buildLocalTranscriptSummary(episodes = []) {
1854
- return {
1855
- count: episodes.length,
1856
- chatRequestIds: episodes.map((episode) => episode.chatRequestId).filter(Boolean),
1857
- latestChatRequestId: episodes[0]?.chatRequestId || null,
1858
- };
1859
- }
1860
-
1861
- export async function augmentConversationPayloadWithLocalTranscriptIndex(payload, {
1862
- workspaceRoot = null,
1863
- filters = {},
1864
- } = {}) {
1865
- if (!isObject(payload)) return payload;
1866
- const episodes = await listClaworldLocalTranscriptEpisodes({ workspaceRoot, filters });
1867
- const addition = {
1868
- localTranscriptEpisodes: episodes,
1869
- localTranscriptSummary: buildLocalTranscriptSummary(episodes),
1870
- };
1871
- if (Array.isArray(payload.items)) {
1872
- return {
1873
- ...payload,
1874
- ...addition,
1875
- items: payload.items.map((item) => {
1876
- if (!isObject(item)) return item;
1877
- const itemFilters = buildEpisodeFilters({
1878
- chatRequestId: item.chatRequestId,
1879
- conversationKey: item.conversationKey,
1880
- localSessionKey: item.localSessionKey,
1881
- worldId: item.worldId,
1882
- counterpartyAgentId: item.counterpartyAgentId || item.targetAgentId || item.peerAgentId,
1883
- });
1884
- const itemEpisodes = episodes.filter((episode) => episodeMatchesFilters(episode, itemFilters));
1885
- if (itemEpisodes.length === 0) return item;
1886
- return {
1887
- ...item,
1888
- localTranscriptEpisodes: itemEpisodes,
1889
- localTranscriptSummary: buildLocalTranscriptSummary(itemEpisodes),
1890
- };
1891
- }),
1892
- };
1893
- }
1894
- return {
1895
- ...payload,
1896
- ...addition,
1897
- };
1898
- }