im_wower
·
2026-03-28
static-gen.ts
1import {
2 ARTIFACT_PUBLIC_PATH_SEGMENT,
3 type ArtifactScope,
4 type ArtifactTextFile,
5 type ExecutionRecord,
6 type MessageRecord,
7 type SessionIndexEntry,
8 type SessionRecord,
9 type SessionTimelineEntry
10} from "./types.js";
11
12const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
13const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
14
15interface ArtifactRenderConfig {
16 publicBaseUrl: string | null;
17}
18
19export function buildArtifactRelativePath(scope: ArtifactScope, fileName: string): string {
20 return `${scope}/${fileName}`;
21}
22
23export function buildArtifactPublicUrl(
24 publicBaseUrl: string | null,
25 relativePath: string | null
26): string | null {
27 if (publicBaseUrl == null || relativePath == null) {
28 return null;
29 }
30
31 return `${publicBaseUrl}/${ARTIFACT_PUBLIC_PATH_SEGMENT}/${relativePath}`;
32}
33
34export function getArtifactContentType(path: string): string {
35 return path.endsWith(".json") ? JSON_CONTENT_TYPE : TEXT_CONTENT_TYPE;
36}
37
38export function buildMessageArtifactFiles(
39 record: MessageRecord,
40 config: ArtifactRenderConfig
41): ArtifactTextFile[] {
42 const txtPath = record.staticPath;
43 const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
44
45 return [
46 {
47 content: renderMessageText(record),
48 kind: "txt",
49 relativePath: txtPath
50 },
51 {
52 content: renderJson({
53 artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
54 conversation_id: record.conversationId,
55 created_at: formatTimestamp(record.createdAt),
56 id: record.id,
57 kind: "message",
58 observed_at: formatTimestamp(record.observedAt),
59 organization_id: record.organizationId,
60 page_title: record.pageTitle,
61 page_url: record.pageUrl,
62 platform: record.platform,
63 raw_text: record.rawText,
64 role: record.role,
65 static_path: txtPath,
66 summary: record.summary
67 }),
68 kind: "json",
69 relativePath: jsonPath
70 }
71 ];
72}
73
74export function buildExecutionArtifactFiles(
75 record: ExecutionRecord,
76 config: ArtifactRenderConfig,
77 messageStaticPath: string | null
78): ArtifactTextFile[] {
79 const txtPath = record.staticPath;
80 const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
81
82 return [
83 {
84 content: renderExecutionText(record, config, messageStaticPath),
85 kind: "txt",
86 relativePath: txtPath
87 },
88 {
89 content: renderJson({
90 artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
91 executed_at: formatTimestamp(record.executedAt),
92 http_status: record.httpStatus,
93 id: record.instructionId,
94 kind: "execution",
95 message_id: record.messageId,
96 message_url: buildArtifactPublicUrl(config.publicBaseUrl, messageStaticPath),
97 params: parseJsonText(record.params),
98 params_kind: record.paramsKind,
99 result: {
100 data: parseJsonText(record.resultData),
101 error: record.resultError,
102 ok: record.resultOk
103 },
104 static_path: txtPath,
105 status: record.resultOk ? "ok" : "error",
106 summary: record.resultSummary,
107 target: record.target,
108 tool: record.tool
109 }),
110 kind: "json",
111 relativePath: jsonPath
112 }
113 ];
114}
115
116export function buildSessionArtifactFiles(
117 record: SessionRecord,
118 timeline: SessionTimelineEntry[],
119 config: ArtifactRenderConfig
120): ArtifactTextFile[] {
121 const txtPath = record.staticPath;
122 const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
123
124 return [
125 {
126 content: renderSessionText(record, timeline, config),
127 kind: "txt",
128 relativePath: txtPath
129 },
130 {
131 content: renderJson({
132 artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
133 conversation_id: record.conversationId,
134 created_at: formatTimestamp(record.createdAt),
135 execution_count: record.executionCount,
136 id: record.id,
137 kind: "session",
138 last_activity_at: formatTimestamp(record.lastActivityAt),
139 message_count: record.messageCount,
140 platform: record.platform,
141 started_at: formatTimestamp(record.startedAt),
142 static_path: txtPath,
143 summary: record.summary,
144 timeline: timeline.map((entry) => serializeTimelineEntry(entry, config.publicBaseUrl))
145 }),
146 kind: "json",
147 relativePath: jsonPath
148 }
149 ];
150}
151
152export function buildSessionIndexArtifactFiles(
153 entries: SessionIndexEntry[],
154 generatedAt: number,
155 config: ArtifactRenderConfig
156): ArtifactTextFile[] {
157 const txtPath = buildArtifactRelativePath("session", "latest.txt");
158 const jsonPath = buildArtifactRelativePath("session", "latest.json");
159
160 return [
161 {
162 content: renderSessionIndexText(entries, generatedAt, config),
163 kind: "txt",
164 relativePath: txtPath
165 },
166 {
167 content: renderJson({
168 count: entries.length,
169 generated_at: formatTimestamp(generatedAt),
170 kind: "session_index",
171 sessions: entries.map((entry) => ({
172 conversation_id: entry.conversationId,
173 execution_count: entry.executionCount,
174 id: entry.id,
175 last_activity_at: formatTimestamp(entry.lastActivityAt),
176 latest_message_url: buildArtifactPublicUrl(config.publicBaseUrl, entry.latestMessageStaticPath),
177 message_count: entry.messageCount,
178 platform: entry.platform,
179 session_url: buildArtifactPublicUrl(config.publicBaseUrl, entry.staticPath),
180 summary: entry.summary
181 }))
182 }),
183 kind: "json",
184 relativePath: jsonPath
185 }
186 ];
187}
188
189function renderMessageText(record: MessageRecord): string {
190 return [
191 renderFrontmatter({
192 conversation_id: record.conversationId,
193 created_at: formatTimestamp(record.createdAt),
194 id: record.id,
195 kind: "message",
196 observed_at: formatTimestamp(record.observedAt),
197 organization_id: record.organizationId,
198 page_title: record.pageTitle,
199 page_url: record.pageUrl,
200 platform: record.platform,
201 role: record.role,
202 summary: record.summary
203 }),
204 "",
205 "---",
206 "",
207 record.rawText
208 ].join("\n");
209}
210
211function renderExecutionText(
212 record: ExecutionRecord,
213 config: ArtifactRenderConfig,
214 messageStaticPath: string | null
215): string {
216 const bodySections: string[] = [];
217 const formattedParams = formatStructuredValue(record.params);
218 const formattedResultData = formatStructuredValue(record.resultData);
219
220 if (formattedParams) {
221 bodySections.push(`params:\n${formattedParams}`);
222 }
223
224 if (record.resultSummary) {
225 bodySections.push(`summary:\n${record.resultSummary}`);
226 }
227
228 if (formattedResultData) {
229 bodySections.push(`result:\n${formattedResultData}`);
230 }
231
232 if (record.resultError) {
233 bodySections.push(`error:\n${record.resultError}`);
234 }
235
236 if (bodySections.length === 0) {
237 bodySections.push("(empty)");
238 }
239
240 return [
241 renderFrontmatter({
242 executed_at: formatTimestamp(record.executedAt),
243 http_status: record.httpStatus,
244 id: record.instructionId,
245 kind: "execution",
246 message_id: record.messageId,
247 message_url: buildArtifactPublicUrl(config.publicBaseUrl, messageStaticPath),
248 params_kind: record.paramsKind,
249 status: record.resultOk ? "ok" : "error",
250 target: record.target,
251 tool: record.tool
252 }),
253 "",
254 "---",
255 "",
256 bodySections.join("\n\n")
257 ].join("\n");
258}
259
260function renderSessionText(
261 record: SessionRecord,
262 timeline: SessionTimelineEntry[],
263 config: ArtifactRenderConfig
264): string {
265 const sections = [
266 renderFrontmatter({
267 conversation_id: record.conversationId,
268 created_at: formatTimestamp(record.createdAt),
269 execution_count: record.executionCount,
270 id: record.id,
271 kind: "session",
272 last_activity_at: formatTimestamp(record.lastActivityAt),
273 message_count: record.messageCount,
274 platform: record.platform,
275 started_at: formatTimestamp(record.startedAt),
276 summary: record.summary
277 }),
278 "",
279 "---",
280 ""
281 ];
282
283 if (record.summary) {
284 sections.push(record.summary, "");
285 }
286
287 for (const entry of timeline) {
288 sections.push(renderTimelineEntry(entry, config.publicBaseUrl), "");
289 }
290
291 return sections.join("\n").trimEnd();
292}
293
294function renderSessionIndexText(
295 entries: SessionIndexEntry[],
296 generatedAt: number,
297 config: ArtifactRenderConfig
298): string {
299 const sections = [
300 renderFrontmatter({
301 count: entries.length,
302 generated_at: formatTimestamp(generatedAt),
303 kind: "session_index"
304 }),
305 "",
306 "---",
307 ""
308 ];
309
310 for (const entry of entries) {
311 sections.push(
312 `## [${formatCompactTimestamp(entry.lastActivityAt)}] ${entry.platform} ${entry.conversationId ?? "no-conversation"}`,
313 `messages: ${entry.messageCount}, executions: ${entry.executionCount}, last_activity: ${formatTimeOnly(entry.lastActivityAt)}`,
314 `latest_message: ${buildArtifactPublicUrl(config.publicBaseUrl, entry.latestMessageStaticPath) ?? "(none)"}`,
315 `session: ${buildArtifactPublicUrl(config.publicBaseUrl, entry.staticPath) ?? "(none)"}`,
316 entry.summary ? `summary: ${entry.summary}` : "summary: (none)",
317 ""
318 );
319 }
320
321 return sections.join("\n").trimEnd();
322}
323
324function renderTimelineEntry(entry: SessionTimelineEntry, publicBaseUrl: string | null): string {
325 if (entry.kind === "message") {
326 return [
327 `### message ${entry.id}`,
328 `role: ${entry.role}`,
329 `observed_at: ${formatTimestamp(entry.observedAt)}`,
330 `url: ${buildArtifactPublicUrl(publicBaseUrl, entry.staticPath) ?? "(none)"}`,
331 `summary: ${entry.summary ?? "(none)"}`
332 ].join("\n");
333 }
334
335 return [
336 `### execution ${entry.instructionId}`,
337 `target: ${entry.target}`,
338 `tool: ${entry.tool}`,
339 `executed_at: ${formatTimestamp(entry.executedAt)}`,
340 `status: ${entry.resultOk ? "ok" : "error"}`,
341 `url: ${buildArtifactPublicUrl(publicBaseUrl, entry.staticPath) ?? "(none)"}`,
342 `summary: ${entry.resultSummary ?? "(none)"}`
343 ].join("\n");
344}
345
346function serializeTimelineEntry(entry: SessionTimelineEntry, publicBaseUrl: string | null): Record<string, unknown> {
347 if (entry.kind === "message") {
348 return {
349 artifact_url: buildArtifactPublicUrl(publicBaseUrl, entry.staticPath),
350 id: entry.id,
351 kind: entry.kind,
352 observed_at: formatTimestamp(entry.observedAt),
353 page_title: entry.pageTitle,
354 page_url: entry.pageUrl,
355 role: entry.role,
356 summary: entry.summary
357 };
358 }
359
360 return {
361 artifact_url: buildArtifactPublicUrl(publicBaseUrl, entry.staticPath),
362 executed_at: formatTimestamp(entry.executedAt),
363 id: entry.instructionId,
364 kind: entry.kind,
365 message_id: entry.messageId,
366 status: entry.resultOk ? "ok" : "error",
367 summary: entry.resultSummary,
368 target: entry.target,
369 tool: entry.tool
370 };
371}
372
373function renderFrontmatter(values: Record<string, string | number | null>): string {
374 const lines: string[] = [];
375
376 for (const [key, value] of Object.entries(values)) {
377 if (value == null) {
378 continue;
379 }
380
381 lines.push(`${key}: ${value}`);
382 }
383
384 return lines.join("\n");
385}
386
387function renderJson(payload: Record<string, unknown>): string {
388 return `${JSON.stringify(payload, null, 2)}\n`;
389}
390
391function replaceArtifactExtension(path: string, extension: ".json" | ".txt"): string {
392 return path.replace(/\.[^.]+$/u, extension);
393}
394
395function parseJsonText(value: string | null): unknown {
396 if (value == null) {
397 return null;
398 }
399
400 const normalized = value.trim();
401
402 if (normalized === "") {
403 return "";
404 }
405
406 try {
407 return JSON.parse(normalized) as unknown;
408 } catch {
409 return value;
410 }
411}
412
413function formatStructuredValue(value: string | null): string {
414 const parsed = parseJsonText(value);
415
416 if (parsed == null) {
417 return "";
418 }
419
420 if (typeof parsed === "string") {
421 return parsed;
422 }
423
424 return JSON.stringify(parsed, null, 2);
425}
426
427function formatTimestamp(value: number): string {
428 const date = new Date(value);
429 const offsetMinutes = -date.getTimezoneOffset();
430 const absoluteOffsetMinutes = Math.abs(offsetMinutes);
431 const sign = offsetMinutes >= 0 ? "+" : "-";
432 const offsetHours = String(Math.floor(absoluteOffsetMinutes / 60)).padStart(2, "0");
433 const offsetRemainder = String(absoluteOffsetMinutes % 60).padStart(2, "0");
434
435 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${offsetHours}:${offsetRemainder}`;
436}
437
438function formatCompactTimestamp(value: number): string {
439 const date = new Date(value);
440 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
441}
442
443function formatTimeOnly(value: number): string {
444 const date = new Date(value);
445 return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
446}
447
448function pad(value: number): string {
449 return String(value).padStart(2, "0");
450}