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

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 +1,933 @@
1
- import { execFile } from 'node:child_process';
2
- import crypto from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
3
2
  import fs from 'node:fs/promises';
4
3
  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
4
 
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
- ]);
5
+ import {
6
+ CLAWORLD_TRANSCRIPT_STYLE_NAME,
7
+ measureTranscriptItem,
8
+ paginateTranscriptItems,
9
+ renderTranscriptPageSvg,
10
+ } from './transcript-report-comic-grid.js';
11
+ import { displayCols } from './transcript-report-stylekit.js';
12
+ import { appendClaworldJournalEvent } from './working-memory.js';
23
13
 
24
14
  const DEFAULT_WIDTH = 720;
25
15
  const DEFAULT_MAX_PAGE_HEIGHT = 2600;
26
- const MIN_PAGE_HEIGHT = 900;
27
- const MAX_PAGE_HEIGHT = 8000;
16
+ const TIME_SPLIT_SECONDS = 5 * 60;
17
+ const SESSION_INDEX_RELATIVE_PATH = path.join('.claworld', 'sessions', 'index.json');
18
+
28
19
  const TOP_LEVEL_RENDER_FIELDS = new Set(['mode', 'stored', 'manual', 'style', 'maxPageHeight']);
29
20
  const MANUAL_RENDER_FIELDS = new Set(['messages', 'title', 'peerProfile', 'localLabel', 'peerLabel']);
21
+ const STORED_RENDER_FIELDS = new Set(['chatRequestId', 'title', 'peerProfile', 'localLabel', 'peerLabel']);
30
22
  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
23
 
111
- function isObject(value) {
112
- return value && typeof value === 'object' && !Array.isArray(value);
113
- }
114
-
115
- function normalizeText(value, fallback = null) {
24
+ const OPERATIONAL_NOTICE_PATTERNS = [
25
+ /^🧭\s*New session:\s+\S+/iu,
26
+ /^🧹\s*Auto-compaction complete(?:\s*\(count \d+\))?\.$/iu,
27
+ /^↪️?\s*Model Fallback:/iu,
28
+ /^↪️?\s*Model Fallback cleared:/iu,
29
+ /^⚠️?\s*Agent failed before reply:/iu,
30
+ /^Sent the (?:reply|opener|Claworld reply)\.?$/iu,
31
+ /^◐\s*Session automatically reset\b/iu,
32
+ ];
33
+ const RUNTIME_ERROR_PATTERNS = [
34
+ /^⚠️?\s*Agent failed before reply:/iu,
35
+ /^LLM request failed:/iu,
36
+ /^LLM request timed out\.$/iu,
37
+ /^LLM request unauthorized\.$/iu,
38
+ /^The AI service is temporarily overloaded\.$/iu,
39
+ /^The AI service returned an error\.$/iu,
40
+ /^⚠️?\s*API rate limit reached\.$/iu,
41
+ /^⚠️?\s*.+\s+returned a billing error\b/iu,
42
+ ];
43
+ const OPERATIONAL_SUFFIX_PATTERNS = [
44
+ /^Usage:\s+.+\s+in\s+\/\s+.+\s+out(?:\s+·\s+est\s+.+)?$/iu,
45
+ ];
46
+
47
+ function text(value, fallback = null) {
116
48
  if (value == null) return fallback;
117
49
  const normalized = String(value).trim();
118
50
  return normalized || fallback;
119
51
  }
120
52
 
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
- });
53
+ function isObject(value) {
54
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
131
55
  }
132
56
 
133
- function assertObject(value, field) {
134
- if (!isObject(value)) inputError(field, `${field} must be an object`);
135
- return value;
57
+ function compactObject(value = {}) {
58
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => (
59
+ entry != null && entry !== '' && (!Array.isArray(entry) || entry.length > 0)
60
+ )));
136
61
  }
137
62
 
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
- }
63
+ function isoNow() {
64
+ return new Date().toISOString();
143
65
  }
144
66
 
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 } };
67
+ async function readJsonObject(filePath, fallback = {}) {
68
+ try {
69
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf8'));
70
+ return isObject(parsed) ? parsed : fallback;
71
+ } catch {
72
+ return fallback;
168
73
  }
74
+ }
169
75
 
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');
76
+ async function atomicWriteText(filePath, content) {
77
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
78
+ const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
79
+ try {
80
+ await fs.writeFile(temporaryPath, content.endsWith('\n') ? content : `${content}\n`, 'utf8');
81
+ await fs.rename(temporaryPath, filePath);
82
+ } finally {
83
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
179
84
  }
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
85
  }
209
86
 
210
- function resolveWorkspaceRoot(workspaceRoot = null) {
211
- return path.resolve(normalizeText(workspaceRoot, null) || process.cwd());
87
+ async function atomicWriteJson(filePath, payload) {
88
+ await atomicWriteText(filePath, JSON.stringify(payload, null, 2));
212
89
  }
213
90
 
214
- function stableJson(value) {
215
- return JSON.stringify(value, Object.keys(value || {}).sort());
91
+ function sessionIndexPath(workspaceRoot) {
92
+ return path.join(workspaceRoot, SESSION_INDEX_RELATIVE_PATH);
216
93
  }
217
94
 
218
- function sha256Buffer(buffer) {
219
- return crypto.createHash('sha256').update(buffer).digest('hex');
95
+ function emptySessionIndex() {
96
+ const now = isoNow();
97
+ return {
98
+ schema: 'claworld.sessions.v1',
99
+ version: 1,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ main: {},
103
+ management: {},
104
+ conversationSessions: {},
105
+ conversationEpisodes: {},
106
+ };
220
107
  }
221
108
 
222
- async function hashFile(filePath) {
223
- return sha256Buffer(await fs.readFile(filePath));
109
+ async function readSessionIndex(workspaceRoot) {
110
+ const index = await readJsonObject(sessionIndexPath(workspaceRoot), emptySessionIndex());
111
+ if (!isObject(index.main)) index.main = {};
112
+ if (!isObject(index.management)) index.management = {};
113
+ if (!isObject(index.conversationSessions)) index.conversationSessions = {};
114
+ if (!isObject(index.conversationEpisodes)) index.conversationEpisodes = {};
115
+ return index;
224
116
  }
225
117
 
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
- }
118
+ function appendUniqueDelivery(deliveries, delivery) {
119
+ const deliveryId = text(delivery?.deliveryId, null);
120
+ if (!deliveryId || (!text(delivery?.commandText, null) && !text(delivery?.contextText, null))) return deliveries;
121
+ const next = Array.isArray(deliveries) ? [...deliveries] : [];
122
+ const existingIndex = next.findIndex((item) => text(item?.deliveryId, null) === deliveryId);
123
+ if (existingIndex >= 0) {
124
+ next[existingIndex] = compactObject({ ...next[existingIndex], ...delivery });
125
+ } else {
126
+ next.push(compactObject(delivery));
127
+ }
128
+ return next;
129
+ }
130
+
131
+ export async function recordClaworldTranscriptEpisode(workspaceRoot, input = {}) {
132
+ const chatRequestId = text(input.chatRequestId, null);
133
+ const deliveryId = text(input.deliveryId, null);
134
+ const commandText = text(input.commandText, null);
135
+ const contextText = text(input.contextText, null);
136
+ if (!workspaceRoot || !chatRequestId || !deliveryId || (!commandText && !contextText)) {
137
+ return { ok: false, updated: false, reason: 'missing_transcript_identity' };
138
+ }
139
+
140
+ const index = await readSessionIndex(workspaceRoot);
141
+ const previous = isObject(index.conversationEpisodes[chatRequestId])
142
+ ? index.conversationEpisodes[chatRequestId]
143
+ : {};
144
+ let deliveries = appendUniqueDelivery(previous.deliveries, {
145
+ deliveryId,
146
+ direction: 'inbound',
147
+ fromAgentId: text(input.fromAgentId, null),
148
+ fromAgentCode: text(input.fromAgentCode, null),
149
+ fromDisplayIdentity: text(input.fromDisplayIdentity, null),
150
+ deliveryType: text(input.deliveryType, null),
151
+ commandText,
152
+ contextText,
153
+ untrustedContext: text(input.untrustedContext, null),
154
+ createdAt: text(input.createdAt, null),
155
+ turnCreatedAt: text(input.turnCreatedAt, null),
156
+ });
157
+ const replyText = text(input.replyText, null);
158
+ if (replyText) {
159
+ deliveries = appendUniqueDelivery(deliveries, {
160
+ deliveryId: `${deliveryId}:reply`,
161
+ direction: 'outbound',
162
+ deliveryType: 'reply',
163
+ fromAgentId: text(input.localAgentId, null),
164
+ commandText: replyText,
165
+ createdAt: text(input.replyCreatedAt, isoNow()),
166
+ turnCreatedAt: text(input.replyCreatedAt, isoNow()),
167
+ });
168
+ }
230
169
 
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);
170
+ const now = isoNow();
171
+ const deliveryIds = deliveries.map((entry) => text(entry?.deliveryId, null)).filter(Boolean);
172
+ index.conversationEpisodes[chatRequestId] = compactObject({
173
+ ...previous,
174
+ chatRequestId,
175
+ chatId: text(input.chatId, previous.chatId),
176
+ lastActiveSessionKey: text(input.localSessionKey, previous.lastActiveSessionKey),
177
+ relaySessionKey: text(input.relaySessionKey, previous.relaySessionKey),
178
+ conversationKey: text(input.conversationKey, previous.conversationKey),
179
+ worldId: text(input.worldId, previous.worldId),
180
+ targetAgentId: text(input.targetAgentId, previous.targetAgentId),
181
+ fromAgentCode: text(input.fromAgentCode, previous.fromAgentCode),
182
+ fromDisplayIdentity: text(input.fromDisplayIdentity, previous.fromDisplayIdentity),
183
+ firstSeenAt: text(previous.firstSeenAt, text(input.createdAt, now)),
184
+ lastSeenAt: replyText
185
+ ? text(input.replyCreatedAt, now)
186
+ : text(input.turnCreatedAt, text(input.createdAt, now)),
187
+ deliveryIds,
188
+ deliveryCount: deliveryIds.length,
189
+ deliveries,
190
+ updatedAt: now,
191
+ });
192
+ index.updatedAt = now;
193
+ await atomicWriteJson(sessionIndexPath(workspaceRoot), index);
194
+ return { ok: true, updated: true, chatRequestId, deliveryCount: deliveryIds.length };
236
195
  }
237
196
 
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);
197
+ function stripOperationalSuffix(content) {
198
+ const lines = String(content ?? '').split(/\r?\n/);
199
+ while (lines.length) {
200
+ const lastLine = String(lines.at(-1) ?? '').trim();
201
+ if (!lastLine) {
202
+ lines.pop();
203
+ continue;
204
+ }
205
+ if (!OPERATIONAL_SUFFIX_PATTERNS.some((pattern) => pattern.test(lastLine))) break;
206
+ lines.pop();
207
+ }
208
+ return lines.join('\n').trim();
209
+ }
210
+
211
+ function classifyReplyContent(content) {
212
+ const rawText = String(content ?? '');
213
+ const normalized = stripOperationalSuffix(rawText);
214
+ if (!normalized) return { text: '', silenceReason: rawText.trim() ? 'operational_notice_only' : 'empty_reply' };
215
+ if (normalized === 'NO_REPLY') return { text: '', silenceReason: 'no_reply' };
216
+ if (RUNTIME_ERROR_PATTERNS.some((pattern) => pattern.test(normalized))) {
217
+ return { text: '', silenceReason: 'runtime_failed_before_reply' };
218
+ }
219
+ if (OPERATIONAL_NOTICE_PATTERNS.some((pattern) => pattern.test(normalized))) {
220
+ return { text: '', silenceReason: 'operational_notice_only' };
221
+ }
222
+ return { text: normalized, silenceReason: null };
223
+ }
224
+
225
+ function renderableTranscriptDelivery(delivery) {
226
+ if (!isObject(delivery) || text(delivery.deliveryType, null) === 'kickoff') return false;
227
+ const commandText = text(delivery.commandText, null);
228
+ return Boolean(commandText && !classifyReplyContent(commandText).silenceReason);
229
+ }
230
+
231
+ function episodeSummary(chatRequestId, entry) {
232
+ const deliveries = Array.isArray(entry.deliveries) ? entry.deliveries : [];
233
+ const renderable = deliveries.filter(renderableTranscriptDelivery);
234
+ const peerMessages = renderable.filter((delivery) => text(delivery.direction, null) !== 'outbound').length;
235
+ return compactObject({
236
+ chatRequestId: text(entry.chatRequestId, chatRequestId),
237
+ chatId: entry.chatId,
238
+ conversationKey: entry.conversationKey,
239
+ relaySessionKey: entry.relaySessionKey,
240
+ lastActiveSessionKey: entry.lastActiveSessionKey,
241
+ worldId: entry.worldId,
242
+ targetAgentId: entry.targetAgentId,
243
+ fromAgentCode: entry.fromAgentCode,
244
+ fromDisplayIdentity: entry.fromDisplayIdentity,
245
+ firstSeenAt: entry.firstSeenAt,
246
+ lastSeenAt: entry.lastSeenAt,
247
+ deliveryCount: entry.deliveryCount,
248
+ renderableMessages: renderable.length,
249
+ peerMessages,
250
+ localMessages: renderable.length - peerMessages,
251
+ });
243
252
  }
244
253
 
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
- };
254
+ function localEpisodeSummaries(index) {
255
+ return Object.entries(isObject(index.conversationEpisodes) ? index.conversationEpisodes : {})
256
+ .filter(([, entry]) => isObject(entry))
257
+ .map(([chatRequestId, entry]) => episodeSummary(chatRequestId, entry))
258
+ .sort((left, right) => String(right.lastSeenAt || right.firstSeenAt || '')
259
+ .localeCompare(String(left.lastSeenAt || left.firstSeenAt || '')));
257
260
  }
258
261
 
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;
262
+ function matchesEpisodeFilters(episode, filters = {}) {
263
+ const checks = {
264
+ chatRequestId: 'chatRequestId',
265
+ conversationKey: 'conversationKey',
266
+ localSessionKey: 'relaySessionKey',
267
+ counterpartyAgentId: 'targetAgentId',
268
+ worldId: 'worldId',
269
+ };
270
+ return Object.entries(checks).every(([filterKey, episodeKey]) => {
271
+ const expected = text(filters[filterKey], null);
272
+ return !expected || text(episode[episodeKey], null) === expected;
273
+ });
269
274
  }
270
275
 
271
- function toIsoTimestamp(value) {
272
- const ms = parseTimestampMs(value);
273
- if (!ms) return null;
274
- return new Date(ms).toISOString();
276
+ function filterLocalEpisodes(episodes, filters = {}) {
277
+ return episodes.filter((episode) => matchesEpisodeFilters(episode, filters)).slice(0, 25);
275
278
  }
276
279
 
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(' ');
280
+ function conversationItemFilters(item = {}) {
281
+ const related = isObject(item.relatedObjects) ? item.relatedObjects : {};
282
+ return compactObject({
283
+ chatRequestId: text(item.chatRequestId, text(related.chatRequestId, null)),
284
+ conversationKey: text(item.conversationKey, text(related.conversationKey, null)),
285
+ localSessionKey: text(item.localSessionKey, text(related.localSessionKey, null)),
286
+ counterpartyAgentId: text(item.counterpartyAgentId, text(related.counterpartyAgentId, null)),
287
+ worldId: text(item.worldId, text(related.worldId, null)),
288
+ });
286
289
  }
287
290
 
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');
291
+ export async function augmentConversationPayloadWithLocalTranscriptIndex({
292
+ workspaceRoot,
293
+ payload,
294
+ filters = {},
295
+ } = {}) {
296
+ if (!workspaceRoot || !isObject(payload)) return payload;
297
+ const index = await readSessionIndex(workspaceRoot);
298
+ const episodes = localEpisodeSummaries(index);
299
+ const matching = filterLocalEpisodes(episodes, filters);
300
+ const result = { ...payload };
301
+ if (matching.length) {
302
+ result.localTranscriptEpisodes = matching;
303
+ result.localTranscriptSummary = {
304
+ episodeCount: matching.length,
305
+ chatRequestIds: matching.map((item) => item.chatRequestId).filter(Boolean),
306
+ };
293
307
  }
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 '';
308
+ if (Array.isArray(result.items)) {
309
+ result.items = result.items.map((item) => {
310
+ if (!isObject(item)) return item;
311
+ const itemFilters = conversationItemFilters(item);
312
+ if (!Object.keys(itemFilters).length) return item;
313
+ const itemMatches = filterLocalEpisodes(episodes, itemFilters);
314
+ if (!itemMatches.length) return item;
315
+ return {
316
+ ...item,
317
+ localTranscriptEpisodes: itemMatches,
318
+ localTranscriptSummary: {
319
+ episodeCount: itemMatches.length,
320
+ chatRequestIds: itemMatches.map((match) => match.chatRequestId).filter(Boolean),
321
+ },
322
+ };
323
+ });
302
324
  }
303
- return String(value);
325
+ return result;
304
326
  }
305
327
 
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();
328
+ function rejectUnknown(name, value, allowed) {
329
+ const extra = Object.keys(value).filter((key) => !allowed.has(key)).sort();
330
+ if (extra.length) throw new Error(`unsupported ${name} parameter(s): ${extra.join(', ')}`);
315
331
  }
316
332
 
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);
333
+ function normalizeRenderRequest(args = {}) {
334
+ if (!isObject(args)) throw new Error('render arguments must be an object');
335
+ rejectUnknown('transcript render', args, TOP_LEVEL_RENDER_FIELDS);
336
+ const mode = text(args.mode, null);
337
+ if (!['stored', 'manual'].includes(mode)) {
338
+ throw new Error('mode is required and must be one of stored or manual');
329
339
  }
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';
340
+ const style = text(args.style, CLAWORLD_TRANSCRIPT_STYLE_NAME);
341
+ if (style !== CLAWORLD_TRANSCRIPT_STYLE_NAME) {
342
+ throw new Error(`unsupported transcript report style: ${style}; expected ${CLAWORLD_TRANSCRIPT_STYLE_NAME}`);
366
343
  }
367
- if (compact === 'like') return 'like';
368
- if (compact === 'dislike') return 'dislike';
369
- return tag.replace(/_/g, ' ').trim().toLowerCase();
344
+ const renderArgs = { mode, style, maxPageHeight: args.maxPageHeight };
345
+ if (mode === 'stored') {
346
+ if (args.manual != null) throw new Error('manual must not be provided when mode=stored');
347
+ if (!isObject(args.stored)) throw new Error('stored must be an object when mode=stored');
348
+ rejectUnknown('stored', args.stored, STORED_RENDER_FIELDS);
349
+ const chatRequestId = text(args.stored.chatRequestId, null);
350
+ if (!chatRequestId) throw new Error('stored.chatRequestId is required when mode=stored');
351
+ for (const key of ['title', 'peerProfile', 'localLabel', 'peerLabel']) {
352
+ renderArgs[key] = args.stored[key];
353
+ }
354
+ return { mode, chatRequestId, renderArgs };
355
+ }
356
+ if (args.stored != null) throw new Error('stored must not be provided when mode=manual');
357
+ if (!isObject(args.manual)) throw new Error('manual must be an object when mode=manual');
358
+ rejectUnknown('manual', args.manual, MANUAL_RENDER_FIELDS);
359
+ for (const key of ['title', 'peerProfile', 'localLabel', 'peerLabel']) {
360
+ if (!text(args.manual[key], null)) throw new Error(`manual.${key} is required when mode=manual`);
361
+ renderArgs[key] = args.manual[key];
362
+ }
363
+ if (!Array.isArray(args.manual.messages) || !args.manual.messages.length) {
364
+ throw new Error('manual.messages must be a non-empty array when mode=manual');
365
+ }
366
+ args.manual.messages.forEach((message, index) => {
367
+ const position = index + 1;
368
+ if (!isObject(message)) throw new Error(`manual.messages[${position}] must be an object`);
369
+ rejectUnknown(`manual.messages[${position}]`, message, MANUAL_MESSAGE_FIELDS);
370
+ if (!['peer', 'local'].includes(text(message.from, null))) {
371
+ throw new Error(`manual.messages[${position}].from must be peer or local`);
372
+ }
373
+ if (!text(message.text, null)) throw new Error(`manual.messages[${position}].text is required`);
374
+ if (!text(message.createdAt, null)) throw new Error(`manual.messages[${position}].createdAt is required`);
375
+ });
376
+ return { mode, messages: args.manual.messages, renderArgs };
370
377
  }
371
378
 
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);
379
+ async function loadSourceMessages(request, workspaceRoot) {
380
+ if (request.mode === 'manual') {
381
+ return { messages: request.messages, summary: { kind: 'manual', messageCount: request.messages.length } };
392
382
  }
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]));
383
+ const index = await readSessionIndex(workspaceRoot);
384
+ const episode = isObject(index.conversationEpisodes?.[request.chatRequestId])
385
+ ? index.conversationEpisodes[request.chatRequestId]
386
+ : null;
387
+ if (!episode) {
388
+ throw new Error(`chatRequestId was not found in local Claworld transcript index: ${request.chatRequestId}`);
395
389
  }
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
- }
390
+ const deliveries = Array.isArray(episode.deliveries) ? episode.deliveries : [];
391
+ if (!deliveries.length) {
392
+ throw new Error(`chatRequestId was found but no deliveries were indexed: ${request.chatRequestId}`);
422
393
  }
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
394
  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,
395
+ messages: deliveries,
396
+ summary: compactObject({
397
+ kind: 'chatRequestId',
398
+ chatRequestId: request.chatRequestId,
399
+ chatId: episode.chatId,
400
+ conversationKey: episode.conversationKey,
401
+ relaySessionKey: episode.relaySessionKey,
402
+ lastActiveSessionKey: episode.lastActiveSessionKey,
403
+ firstSeenAt: episode.firstSeenAt,
404
+ lastSeenAt: episode.lastSeenAt,
405
+ indexSource: 'conversationEpisodes',
406
+ }),
471
407
  };
472
408
  }
473
409
 
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;
410
+ function markdownSection(value, title, level) {
411
+ const hashes = '#'.repeat(level);
412
+ const pattern = new RegExp(`^${hashes}\\s+${title}\\s*$`, 'imu');
413
+ const match = pattern.exec(value);
414
+ if (!match) return '';
415
+ const start = match.index + match[0].length;
416
+ const remainder = value.slice(start);
417
+ const nextHeading = new RegExp(`^#{1,${level}}\\s+`, 'mu').exec(remainder);
418
+ return remainder.slice(0, nextHeading?.index ?? remainder.length).trim();
419
+ }
420
+
421
+ function markdownNamedCodeBlock(value, title, level) {
422
+ const section = markdownSection(value, title, level);
423
+ if (!section) return '';
424
+ const fenced = /```[^\n]*\n([\s\S]*?)\n```/u.exec(section);
425
+ if (fenced) return fenced[1].trim();
426
+ return section.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#')).join('\n');
427
+ }
428
+
429
+ function squashWhitespace(value) {
430
+ const lines = String(value ?? '').split(/\r?\n/).map((line) => line.replace(/[ \t]+/gu, ' ').trim());
431
+ const compact = [];
432
+ let blank = false;
433
+ for (const line of lines) {
434
+ if (!line) {
435
+ if (!blank && compact.length) compact.push('');
436
+ blank = true;
497
437
  } else {
498
- text = stripInternalEnvelopeSections(text);
438
+ compact.push(line);
439
+ blank = false;
499
440
  }
500
- } else {
501
- text = stripInternalEnvelopeSections(text);
502
- if (/^NO_REPLY$/i.test(text.trim())) return null;
503
441
  }
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);
442
+ return compact.join('\n').trim();
443
+ }
444
+
445
+ function parseHeaderContextCandidate(value, source) {
446
+ const content = String(value ?? '').trim();
447
+ if (!content) return {};
448
+ const parsed = {};
449
+ const modeMatch = /^\s*-\s*Mode:\s*`?([A-Za-z_-]+)`?\s*$/imu.exec(content)
450
+ || /\bconversation[_\s-]*mode\b\s*[:=]\s*`?([A-Za-z_-]+)`?/iu.exec(content);
451
+ const mode = text(modeMatch?.[1]?.toLowerCase(), null);
452
+ if (['world', 'direct'].includes(mode)) parsed.conversationMode = mode;
453
+ const worldMatch = /^\s*-\s*World:\s*([^\n(`]+?)\s*(?:\(`([^`]+)`\))?\s*$/imu.exec(content);
454
+ if (worldMatch) {
455
+ parsed.worldName = text(worldMatch[1], null);
456
+ parsed.worldId = text(worldMatch[2], null);
457
+ }
458
+ const localSection = markdownSection(content, 'You', 2);
459
+ const peerSection = markdownSection(content, 'Peer', 2);
460
+ const identity = (section) => text(/^\s*-\s*Identity:\s*`?([^`\n]+)`?\s*$/imu.exec(section)?.[1], null);
461
+ if (localSection) parsed.localIdentity = identity(localSection);
462
+ if (peerSection) {
463
+ parsed.peerIdentity = identity(peerSection);
464
+ const globalProfile = markdownNamedCodeBlock(peerSection, 'Global Profile', 3);
465
+ const worldProfile = markdownNamedCodeBlock(peerSection, 'World Membership Profile', 3);
466
+ if (globalProfile) {
467
+ parsed.globalProfile = squashWhitespace(globalProfile);
468
+ parsed.globalProfileSource = source;
566
469
  }
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.
470
+ if (worldProfile) {
471
+ parsed.worldProfile = squashWhitespace(worldProfile);
472
+ parsed.worldProfileSource = source;
615
473
  }
616
474
  }
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
- });
475
+ if (!localSection && !peerSection && source === 'untrustedContext') {
476
+ const plainProfile = plainProfileCandidate(content);
477
+ if (plainProfile) {
478
+ parsed.globalProfile = plainProfile;
479
+ parsed.globalProfileSource = source;
661
480
  }
662
481
  }
663
- return records;
482
+ return compactObject(parsed);
664
483
  }
665
484
 
666
- async function pathExists(filePath) {
667
- try {
668
- await fs.access(filePath);
669
- return true;
670
- } catch {
671
- return false;
672
- }
485
+ function plainProfileCandidate(value) {
486
+ const candidate = squashWhitespace(value);
487
+ if (!candidate || candidate.includes('# ') || candidate.includes('```')) return '';
488
+ return displayCols(candidate) <= 420 ? candidate : '';
673
489
  }
674
490
 
675
- function artifactPathCandidates(artifact = {}) {
676
- return [
677
- normalizeText(artifact.transcriptPath, null),
678
- normalizeText(artifact.sessionFile, null),
679
- ].filter(Boolean);
491
+ function headerProfileSourcePriority(source) {
492
+ return { contextText: 4, untrustedContext: 3, rawKickoffText: 2, transcript: 1 }[source] || 0;
680
493
  }
681
494
 
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
- };
495
+ function mergeHeaderContextCandidate(merged, candidate, source) {
496
+ if (!candidate) return;
497
+ const parsed = parseHeaderContextCandidate(candidate, source);
498
+ for (const [key, value] of Object.entries(parsed)) {
499
+ if (!text(value, null)) continue;
500
+ const sourceKey = {
501
+ globalProfile: 'globalProfileSource',
502
+ worldProfile: 'worldProfileSource',
503
+ globalProfileSource: 'globalProfileSource',
504
+ worldProfileSource: 'worldProfileSource',
505
+ }[key];
506
+ if (!sourceKey) {
507
+ merged[key] = value;
508
+ } else {
509
+ const incomingSource = key.endsWith('Source') ? value : parsed[sourceKey];
510
+ if (headerProfileSourcePriority(incomingSource) >= headerProfileSourcePriority(merged[sourceKey])) {
511
+ merged[key] = value;
691
512
  }
692
513
  }
693
514
  }
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
515
  }
727
516
 
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}`);
517
+ function extractTranscriptHeaderContext(rawMessages) {
518
+ const merged = {};
519
+ for (const raw of rawMessages) {
520
+ if (!isObject(raw)) continue;
521
+ mergeHeaderContextCandidate(merged, text(raw.contextText, null), 'contextText');
522
+ mergeHeaderContextCandidate(merged, text(raw.untrustedContext, null), 'untrustedContext');
523
+ if (text(raw.deliveryType, null) === 'kickoff') {
524
+ mergeHeaderContextCandidate(merged, text(raw.commandText, null), 'rawKickoffText');
525
+ }
526
+ if (!merged.peerIdentity && text(raw.fromDisplayIdentity, null)) {
527
+ merged.peerIdentity = text(raw.fromDisplayIdentity, null);
738
528
  }
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
529
  }
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;
530
+ let peerProfile = '';
531
+ let profileSource = '';
532
+ if (merged.conversationMode === 'world' && merged.worldProfile) {
533
+ peerProfile = merged.worldProfile;
534
+ profileSource = merged.worldProfileSource || 'transcript';
535
+ } else if (merged.conversationMode === 'direct' && merged.globalProfile) {
536
+ peerProfile = merged.globalProfile;
537
+ profileSource = merged.globalProfileSource || 'transcript';
538
+ } else if (merged.worldProfile) {
539
+ peerProfile = merged.worldProfile;
540
+ profileSource = merged.worldProfileSource || 'transcript';
541
+ } else if (merged.globalProfile) {
542
+ peerProfile = merged.globalProfile;
543
+ profileSource = merged.globalProfileSource || 'transcript';
544
+ }
545
+ return compactObject({ ...merged, peerProfile, profileSource });
546
+ }
547
+
548
+ function formatTimestamp(value) {
549
+ if (value == null || value === '') return '';
550
+ let date = null;
551
+ if (typeof value === 'number' && Number.isFinite(value)) {
552
+ date = new Date(value * 1000);
553
+ } else {
554
+ const parsed = new Date(String(value).trim());
555
+ if (!Number.isNaN(parsed.getTime())) date = parsed;
828
556
  }
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;
557
+ if (!date) return String(value).trim();
558
+ const pad = (part) => String(part).padStart(2, '0');
559
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
842
560
  }
843
561
 
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}`;
562
+ function parseTimeish(value) {
563
+ if (value == null || value === '') return null;
564
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
565
+ if (/^\d+(?:\.\d+)?$/u.test(String(value).trim())) return Number(value);
566
+ const timestamp = Date.parse(String(value).trim());
567
+ return Number.isNaN(timestamp) ? null : timestamp / 1000;
857
568
  }
858
569
 
859
- function wrapTokens(paragraph) {
860
- const tokens = [];
861
- let current = '';
862
- const flush = () => {
863
- if (current) {
864
- tokens.push(current);
865
- current = '';
866
- }
570
+ function extractControlTags(value) {
571
+ const matches = [];
572
+ const claimed = [];
573
+ const patterns = [
574
+ [/\[\[?\s*request[_\s-]*(?:conversation[_\s-]*)?end\s*\]?\]?/giu, 'request end'],
575
+ [/\[\s*requeset\s+end\s*\]/giu, 'request end'],
576
+ [/\[\[?\s*end\s*\]?\]?/giu, 'request end'],
577
+ [/\[\[?\s*like\s*\]?\]?/giu, 'like'],
578
+ [/\[\[?\s*dislike\s*\]?\]?/giu, 'dislike'],
579
+ ];
580
+ const record = (start, end, label) => {
581
+ if (claimed.some(([claimedStart, claimedEnd]) => start < claimedEnd && end > claimedStart)) return;
582
+ claimed.push([start, end]);
583
+ matches.push([start, end, label]);
867
584
  };
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());
585
+ const source = String(value ?? '');
586
+ for (const [pattern, label] of patterns) {
587
+ for (const match of source.matchAll(pattern)) record(match.index, match.index + match[0].length, label);
923
588
  }
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())}`;
589
+ for (const match of source.matchAll(/\[\[\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,24})\s*\]\]/gu)) {
590
+ const label = squashWhitespace(match[1].replaceAll('_', ' ').replaceAll('-', ' ')).toLowerCase().slice(0, 24).trim();
591
+ if (label) record(match.index, match.index + match[0].length, label);
933
592
  }
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]}`;
593
+ const sorted = matches.sort((left, right) => left[0] - right[0]);
594
+ const tags = [...new Set(sorted.map(([, , label]) => label))];
595
+ let cursor = 0;
596
+ const pieces = [];
597
+ for (const [start, end] of sorted) {
598
+ pieces.push(source.slice(cursor, start));
599
+ cursor = Math.max(cursor, end);
938
600
  }
939
- return '';
601
+ pieces.push(source.slice(cursor));
602
+ return { text: squashWhitespace(pieces.join('')), tags };
940
603
  }
941
604
 
942
- function timeLabelForMessage(message) {
943
- return formatCompactTimestamp(message.createdAt || message.timestampMs || message.createdAtLabel)
944
- || message.createdAtLabel
945
- || '';
605
+ function redactText(value) {
606
+ return String(value ?? '')
607
+ .replace(/\b(api[_-]?key|app[_-]?token|access[_-]?token|refresh[_-]?token|secret|password|authorization|bearer)\b\s*[:=]\s*[^\s,;]+/giu, '$1=[redacted]')
608
+ .replace(/\b(?:sk|rk|pk|ghp|glpat)-[A-Za-z0-9_-]{12,}\b/gu, '[redacted-token]')
609
+ .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gu, '[redacted-email]')
610
+ .replace(/(?<!\d)(?:\+?\d[\d\s().-]{8,}\d)(?!\d)/gu, '[redacted-phone]');
946
611
  }
947
612
 
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
- });
613
+ function stripInternalMarkup(value) {
614
+ let cleaned = String(value ?? '').replace(/```(?:json|text|markdown)?/giu, '').replaceAll('```', '');
615
+ for (const marker of [
616
+ 'Routing metadata:',
617
+ 'Claworld live conversation rules:',
618
+ 'Backend-authored Claworld context:',
619
+ 'Backend-authored Claworld command:',
620
+ 'Relay untrusted context:',
621
+ ]) {
622
+ if (cleaned.includes(marker)) cleaned = cleaned.split(marker, 1)[0];
623
+ }
624
+ return squashWhitespace(cleaned);
625
+ }
626
+
627
+ function normalizeMessages(rawMessages, localAgentId, args, headerContext = {}) {
628
+ const localIdentity = text(headerContext.localIdentity, null);
629
+ const peerIdentity = text(headerContext.peerIdentity, text(headerContext.peerId, null));
630
+ const localId = text(localIdentity, text(localAgentId, 'local-agent'));
631
+ const peerId = text(peerIdentity, 'peer-agent');
632
+ const localLabel = publicHeaderValue(args.localLabel) || publicHeaderValue(localIdentity) || 'Me';
633
+ const peerLabel = publicHeaderValue(args.peerLabel) || publicHeaderValue(peerIdentity) || 'Peer';
634
+ const normalized = [];
635
+ rawMessages.forEach((raw, index) => {
636
+ if (!isObject(raw)) return;
637
+ let side;
638
+ let messageText;
639
+ let createdAt;
640
+ let messageId;
641
+ let participantId;
642
+ let participantLabel;
643
+ if (['peer', 'local'].includes(raw.from)) {
644
+ side = raw.from === 'peer' ? 'left' : 'right';
645
+ messageText = text(raw.text, null);
646
+ createdAt = formatTimestamp(raw.createdAt);
647
+ messageId = text(raw.id, `msg-${index + 1}`);
648
+ participantId = side === 'left' ? peerId : localId;
649
+ participantLabel = side === 'left' ? peerLabel : localLabel;
650
+ } else {
651
+ if (text(raw.deliveryType, null) === 'kickoff') return;
652
+ const classification = classifyReplyContent(text(raw.commandText, ''));
653
+ if (classification.silenceReason) return;
654
+ messageText = classification.text;
655
+ createdAt = formatTimestamp(raw.turnCreatedAt || raw.createdAt);
656
+ messageId = text(raw.deliveryId, `msg-${index + 1}`);
657
+ side = text(raw.direction, null) === 'outbound'
658
+ || (!text(raw.direction, null) && text(raw.fromAgentId, null) === text(localAgentId, null))
659
+ ? 'right'
660
+ : 'left';
661
+ participantId = side === 'left' ? peerId : localId;
662
+ participantLabel = side === 'left' ? peerLabel : localLabel;
960
663
  }
961
- items.push({
962
- kind: 'message',
963
- message,
664
+ if (!messageText) return;
665
+ const extracted = extractControlTags(messageText);
666
+ const cleanedText = stripInternalMarkup(redactText(extracted.text));
667
+ if (!cleanedText && !extracted.tags.length) return;
668
+ normalized.push({
669
+ id: messageId,
670
+ side,
671
+ participantId,
672
+ participantLabel,
673
+ text: cleanedText,
674
+ createdAt,
675
+ tags: extracted.tags,
676
+ sourceIndex: index,
964
677
  });
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
678
  });
1125
-
1126
- return {
1127
- width,
1128
- header,
1129
- pages,
1130
- };
679
+ return normalized;
1131
680
  }
1132
681
 
1133
- function escapeXml(value) {
1134
- return String(value ?? '')
1135
- .replace(/&/g, '&amp;')
1136
- .replace(/</g, '&lt;')
1137
- .replace(/>/g, '&gt;')
1138
- .replace(/"/g, '&quot;');
682
+ function selectionSummary(request, messageCount) {
683
+ return compactObject({
684
+ mode: request.mode,
685
+ chatRequestId: request.mode === 'stored' ? request.chatRequestId : null,
686
+ messageCount,
687
+ omittedBefore: 0,
688
+ omittedAfter: 0,
689
+ });
1139
690
  }
1140
691
 
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;
692
+ function formatTimeMarker(value) {
693
+ const timestamp = parseTimeish(value);
694
+ if (timestamp != null) {
695
+ const date = new Date(timestamp * 1000);
696
+ if (!Number.isNaN(date.getTime())) {
697
+ const pad = (part) => String(part).padStart(2, '0');
698
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1165
699
  }
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
700
  }
1172
- parts.push('</svg>');
1173
- return parts.join('\n');
701
+ const match = /(?:(\d{4})[-/])?(\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{2})/u.exec(String(value ?? '').trim());
702
+ return match ? `${String(Number(match[2])).padStart(2, '0')}-${String(Number(match[3])).padStart(2, '0')} ${String(Number(match[4])).padStart(2, '0')}:${match[5]}` : '';
1174
703
  }
1175
704
 
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' };
705
+ function decorateSelection(messages, selection) {
706
+ const items = [];
707
+ let previousTimestamp = null;
708
+ messages.forEach((message, index) => {
709
+ const timestamp = parseTimeish(message.createdAt);
710
+ if (message.createdAt && (index === 0 || (
711
+ previousTimestamp != null && timestamp != null && timestamp - previousTimestamp > TIME_SPLIT_SECONDS
712
+ ))) {
713
+ const label = formatTimeMarker(message.createdAt);
714
+ if (label) items.push({ kind: 'time', label, timestamp: message.createdAt });
715
+ }
716
+ items.push({ kind: 'message', message });
717
+ previousTimestamp = timestamp ?? previousTimestamp;
718
+ });
719
+ if (selection.omittedAfter > 0) {
720
+ items.push({ kind: 'ellipsis', omitted: selection.omittedAfter, label: `${selection.omittedAfter} later messages omitted` });
1183
721
  }
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');
722
+ return items;
1251
723
  }
1252
724
 
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;
725
+ function participants(messages) {
726
+ const seen = new Map();
727
+ for (const message of messages) {
728
+ if (!seen.has(message.participantId)) seen.set(message.participantId, message);
1269
729
  }
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');
730
+ return [...seen.entries()].map(([participantId, message]) => ({
731
+ id: participantId,
732
+ name: message.participantLabel,
733
+ side: message.side,
734
+ avatar: String(message.participantLabel || 'A').replace(/[^A-Za-z0-9]/gu, '').slice(0, 2).toUpperCase() || 'A',
735
+ }));
1286
736
  }
1287
737
 
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
- }
738
+ function bubbleMessagePayload(item) {
739
+ if (item.kind === 'time') return { kind: 'time', label: item.label, timestamp: item.timestamp || '' };
740
+ if (item.kind === 'ellipsis') return { kind: 'ellipsis', omitted: item.omitted, label: item.label };
741
+ const { message } = item;
1296
742
  return {
1297
- fill: COMIC_THEME.leftFill,
1298
- label: COMIC_THEME.leftLabel,
1299
- accent: 'url(#leftAccent)',
743
+ id: message.id,
744
+ kind: 'text',
745
+ from: message.participantId,
746
+ side: message.side,
747
+ speaker: message.participantLabel,
748
+ text: message.text,
749
+ createdAt: message.createdAt,
750
+ tags: [...message.tags],
1300
751
  };
1301
752
  }
1302
753
 
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;
754
+ function publicHeaderValue(value) {
755
+ const normalized = text(value, '') || '';
756
+ if (/(?:^|[\s(])(?:agt|req|wld|dlv|conversation|management)[_:-][a-z0-9]/iu.test(normalized)) {
757
+ return '';
1329
758
  }
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>`;
759
+ return normalized;
1387
760
  }
1388
761
 
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');
762
+ function displayName(identity) {
763
+ return publicHeaderValue(String(identity || '').split('#', 1)[0]);
1401
764
  }
1402
765
 
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');
766
+ function semanticHeaderTitle(peerName, worldName) {
767
+ if (peerName && worldName) return `${peerName} — ${worldName}`;
768
+ return peerName || worldName || 'Claworld conversation';
1410
769
  }
1411
770
 
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');
771
+ function headerText(args, messages, headerContext) {
772
+ const explicitTitle = publicHeaderValue(args.title);
773
+ let peerIdentity = publicHeaderValue(headerContext.peerIdentity)
774
+ || publicHeaderValue(headerContext.peerId);
775
+ if (!peerIdentity) {
776
+ peerIdentity = publicHeaderValue(
777
+ messages.find((message) => message.side === 'left')?.participantLabel,
778
+ );
779
+ }
780
+ const peerName = displayName(peerIdentity);
781
+ const worldName = publicHeaderValue(headerContext.worldName);
782
+ const title = explicitTitle || semanticHeaderTitle(peerName, worldName);
783
+ const explicitProfile = publicHeaderValue(args.peerProfile);
784
+ if (explicitProfile) return { title, subtitle: explicitProfile };
785
+ const profile = publicHeaderValue(headerContext.peerProfile);
786
+ const subtitleParts = [peerIdentity, profile].filter(Boolean);
787
+ if (!subtitleParts.length && worldName) subtitleParts.push(worldName);
788
+ return { title, subtitle: subtitleParts.join(' · ') || 'Conversation transcript' };
1447
789
  }
1448
790
 
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
- };
791
+ function outputDirectories(workspaceRoot) {
792
+ const base = path.join(workspaceRoot, '.claworld', 'reports', 'transcripts');
793
+ return { images: path.join(base, 'images'), documents: path.join(base, 'documents') };
1480
794
  }
1481
795
 
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;
796
+ function artifactId(source, selection, styleName) {
797
+ const now = new Date();
798
+ const pad = (part) => String(part).padStart(2, '0');
799
+ const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
800
+ const styleSlug = styleName.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || 'style';
801
+ const digest = createHash('sha256').update(JSON.stringify({ source, selection, style: styleName, now: timestamp })).digest('hex').slice(0, 10);
802
+ return `claworld-transcript-${styleSlug}-${timestamp}-${digest}`;
1492
803
  }
1493
804
 
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;
805
+ async function sha256(filePath) {
806
+ return createHash('sha256').update(await fs.readFile(filePath)).digest('hex');
1502
807
  }
1503
808
 
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]);
809
+ function intValue(value, fallback, minimum, maximum) {
810
+ const parsed = Number.parseInt(value, 10);
811
+ return Math.min(maximum, Math.max(minimum, Number.isFinite(parsed) ? parsed : fallback));
1511
812
  }
1512
813
 
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
- }
814
+ async function writePng(svg, pngPath, page) {
815
+ const sharpModule = await import('sharp');
816
+ const sharp = sharpModule.default || sharpModule;
817
+ await sharp(Buffer.from(svg), { density: 192 })
818
+ .resize(page.width, page.height, { fit: 'fill' })
819
+ .png()
820
+ .toFile(pngPath);
821
+ return { renderer: 'sharp-svg' };
1528
822
  }
1529
823
 
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
- ]);
824
+ function artifactPage(item) {
825
+ return compactObject({
826
+ page: item.page,
827
+ format: item.format,
828
+ path: item.path,
829
+ width: item.width,
830
+ height: item.height,
831
+ sha256: item.sha256,
832
+ mediaRef: item.format === 'png' ? `MEDIA:${item.path}` : null,
833
+ });
1565
834
  }
1566
835
 
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
- }
836
+ export async function renderTranscriptReport({ workspaceRoot, localAgentId = null, args = {} } = {}) {
837
+ if (!workspaceRoot) throw new Error('OpenClaw workspace root is required for transcript rendering');
838
+ const request = normalizeRenderRequest(args);
839
+ const source = await loadSourceMessages(request, workspaceRoot);
840
+ const headerContext = extractTranscriptHeaderContext(source.messages);
841
+ const normalized = normalizeMessages(source.messages, localAgentId, request.renderArgs, headerContext);
842
+ if (!normalized.length) throw new Error('no visible transcript messages were found for rendering');
1587
843
 
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),
844
+ const selection = selectionSummary(request, normalized.length);
845
+ const width = DEFAULT_WIDTH;
846
+ const maxPageHeight = intValue(request.renderArgs.maxPageHeight, DEFAULT_MAX_PAGE_HEIGHT, 900, 8000);
847
+ const header = headerText(request.renderArgs, normalized, headerContext);
848
+ const decorated = decorateSelection(normalized, selection);
849
+ const measured = decorated.map((item) => measureTranscriptItem(item, width));
850
+ const pages = paginateTranscriptItems(measured, width, maxPageHeight, header.title, header.subtitle);
851
+ const currentArtifactId = artifactId(source.summary, selection, CLAWORLD_TRANSCRIPT_STYLE_NAME);
852
+ const directories = outputDirectories(workspaceRoot);
853
+ await Promise.all([fs.mkdir(directories.images, { recursive: true }), fs.mkdir(directories.documents, { recursive: true })]);
854
+
855
+ const files = [];
856
+ for (const page of pages) {
857
+ const suffix = `p${String(page.page).padStart(2, '0')}`;
858
+ const svgPath = path.join(directories.documents, `${currentArtifactId}-${suffix}.svg`);
859
+ const pngPath = path.join(directories.images, `${currentArtifactId}-${suffix}.png`);
860
+ const svg = renderTranscriptPageSvg(page);
861
+ await atomicWriteText(svgPath, svg);
862
+ const pngResult = await writePng(svg, pngPath, page);
863
+ files.push({ page: page.page, format: 'svg', path: svgPath, width: page.width, height: page.height, sha256: await sha256(svgPath), role: 'source' });
864
+ files.push({ page: page.page, format: 'png', path: pngPath, width: page.width, height: page.height, sha256: await sha256(pngPath), role: 'primary', renderer: pngResult.renderer });
865
+ }
866
+
867
+ const bubbleSpec = {
868
+ version: '1',
869
+ kind: 'claworld.transcript_report',
870
+ scene: {
871
+ title: header.title,
872
+ subtitle: header.subtitle,
873
+ peerId: header.title,
874
+ peerProfile: header.subtitle,
875
+ peerProfileSource: publicHeaderValue(request.renderArgs.peerProfile)
876
+ ? 'explicit'
877
+ : headerContext.profileSource || 'fallback',
878
+ generatedAt: isoNow(),
879
+ source: source.summary,
880
+ selection,
1628
881
  },
1629
- svgPages,
1630
- pngPages,
882
+ canvas: { width, style: CLAWORLD_TRANSCRIPT_STYLE_NAME, maxPageHeight },
883
+ participants: participants(normalized),
884
+ messages: decorated.map(bubbleMessagePayload),
1631
885
  };
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 } : {}),
886
+ const specPath = path.join(directories.documents, `${currentArtifactId}.bubblespec.json`);
887
+ await atomicWriteJson(specPath, bubbleSpec);
888
+
889
+ const pngPages = files.filter((item) => item.format === 'png').map(artifactPage);
890
+ const svgPages = files.filter((item) => item.format === 'svg').map(artifactPage);
891
+ const stats = {
892
+ sourceMessages: source.messages.length,
893
+ normalizedMessages: normalized.length,
894
+ renderedMessages: normalized.length,
895
+ pages: pages.length,
896
+ omittedBefore: selection.omittedBefore || 0,
897
+ omittedAfter: selection.omittedAfter || 0,
1644
898
  };
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),
899
+ const result = compactObject({
900
+ status: 'ok',
901
+ mode: request.mode,
902
+ chatRequestId: request.mode === 'stored' ? request.chatRequestId : null,
903
+ artifactId: currentArtifactId,
904
+ messageCount: normalized.length,
905
+ pageCount: pages.length,
906
+ style: CLAWORLD_TRANSCRIPT_STYLE_NAME,
907
+ artifacts: {
908
+ bubbleSpec: { format: 'bubblespec', path: specPath, sha256: await sha256(specPath) },
909
+ pngPages,
910
+ svgPages,
1659
911
  },
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,
912
+ deliveryHint: {
913
+ primaryMedia: pngPages[0]?.mediaRef || null,
914
+ primaryMediaBatch: pngPages.map((item) => item.mediaRef).join('\n'),
915
+ sourceSvgDocument: svgPages[0] ? `[[as_document]]\nMEDIA:${svgPages[0].path}` : null,
916
+ reportOwnerArgs: {
917
+ media_path: pngPages[0]?.path || null,
918
+ media_paths: pngPages.map((item) => item.path),
919
+ send_source_svg: false,
1705
920
  },
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,
921
+ },
922
+ diagnostics: { source: source.summary, stats },
1735
923
  });
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,
924
+ await appendClaworldJournalEvent(workspaceRoot, {
925
+ kind: 'transcript_report',
926
+ scope: request.mode === 'stored' ? 'conversation' : 'main',
927
+ summary: `Rendered ${normalized.length} visible Claworld transcript messages across ${pages.length} page(s).`,
928
+ refs: { chatRequestId: request.mode === 'stored' ? request.chatRequestId : null },
929
+ artifacts: { artifactId: currentArtifactId, bubbleSpec: specPath, pngPages: pngPages.map((item) => item.path), svgPages: svgPages.map((item) => item.path) },
930
+ maintenance: { selection, stats },
1743
931
  });
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
932
  return result;
1774
933
  }
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
- }