Skip to content

transcript.kt

src/jvmMain/kotlin/afield/tools/automation/transcript.kt · 949 lines · cited by What a document is on disk

package and imports — 46 lines, from line 1
package afield.tools.automation

import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.introduction
import afield.document.graph.preamble
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.output.column
import afield.ui.output.transcript
import afield.ui.visualization.h2

import afield.app.protocol.Execute
import afield.app.protocol.ExecuteSymbol
import afield.app.protocol.TranscriptLineActivity
import afield.app.protocol.TranscriptLineActivityResult
import afield.app.protocol.computeIfAbsent
import afield.app.protocol.payload
import afield.app.server.thisSessionInfo
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.Box
import afield.document.graph.box
import afield.document.persistence.Edn
import afield.document.persistence.SavingContext
import afield.document.persistence.safeSave
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.execution.launches
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import afield.ui.visualization.facts
import afield.ui.visualization.h

import afield.app.diagnostics.logging.lprintln
import java.io.File
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap

Keeps a per-document log of what ran in each box, for activity summaries and the transcript view.

FlightRecorder appends an entry for every execute, launch and stop, and for changes to tracked box properties, then reports per-box execution and launch counts (activitySummaryForDocument, stamped with the current Field launch id), per-line activity (lineActivityForDocument) and an HTML rendering of the log (htmlForDocument). Older logs are kept as transcript_backup_*.edn files in the document's backups/ folder.

source — 53 lines, from line 58
private val launchSnapshotProperties = listOf(
    "code",
    "vertex",
    "fragment",
    "stackSL",
    "vertex_fragment",
    "task",
    "mesh",
    "compute",
    "raygen",
    "miss",
    "closesthit",
    "anyhit",
    "intersection",
    "callable",
    "vertex_s",
    "fragment_s",
    "task_s",
    "mesh_s",
    "compute_s",
    "raygen_s",
    "miss_s",
    "closesthit_s",
    "anyhit_s",
    "intersection_s",
    "callable_s"
)
private val launchedPropertyChangeProperties = setOf(
    "vertex",
    "fragment",
    "stackSL",
    "vertex_fragment",
    "task",
    "mesh",
    "compute",
    "raygen",
    "miss",
    "closesthit",
    "anyhit",
    "intersection",
    "callable",
    "vertex_s",
    "fragment_s",
    "task_s",
    "mesh_s",
    "compute_s",
    "raygen_s",
    "miss_s",
    "closesthit_s",
    "anyhit_s",
    "intersection_s",
    "callable_s"
)

object FlightRecorder

object FlightRecorder {

data class BoxActivitySummary

    data class BoxActivitySummary(
        val uid: String,
        val name: String,
        val executions: Int,
        val launches: Int
    )

data class ActivitySummary

    data class ActivitySummary(
        val fieldLaunchId: String,
        val fieldLaunchStartedAt: Long,
        val executions: Int,
        val launches: Int,
        val perBox: List<BoxActivitySummary>
    )

    private data class DocumentState(
        val documentPath: String,
        val lock: Any = Any(),
        val entries: MutableList<LinkedHashMap<String, Any?>> = mutableListOf(),
        val launchedBoxes: MutableSet<String> = mutableSetOf(),
        var loaded: Boolean = false,
        var dirty: Boolean = false,
        var previousProcessArchived: Boolean = false
    )

    private val states = ConcurrentHashMap<String, DocumentState>()
    private val fieldLaunchStartedAt = System.currentTimeMillis()
    private val fieldLaunchId = buildString {
        append(Instant.ofEpochMilli(fieldLaunchStartedAt).toString())
        append("#")
        append(runCatching { ProcessHandle.current().pid().toString() }.getOrDefault("process"))
    }

fun recordExecuteEntrypoint(entrypoint: String, box: Box, execute: Execute)

    fun recordExecuteEntrypoint(entrypoint: String, box: Box, execute: Execute) {
        val documentPath = documentPathFor(box) ?: return
        append(stateFor(documentPath)) { state, nowMs, nowIso ->
            eventBase(
                state = state,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "execute",
                entrypoint = entrypoint
            ).apply {
                this["type"] = execute.type
                this["line"] = execute.line
                this["strand"] = execute.strand
                this["singleLine"] = execute.singleLine
                this["code"] = execute.code
            }
        }
    }

fun recordExecuteSymbolEntrypoint(entrypoint: String, box: Box, executeSymbol: ExecuteSymbol)

    fun recordExecuteSymbolEntrypoint(entrypoint: String, box: Box, executeSymbol: ExecuteSymbol) {
        val documentPath = documentPathFor(box) ?: return
        append(stateFor(documentPath)) { state, nowMs, nowIso ->
            eventBase(
                state = state,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "execute-symbol",
                entrypoint = entrypoint
            ).apply {
                this["line"] = executeSymbol.line
                this["symbol"] = executeSymbol.symbol
                this["code"] = "${executeSymbol.symbol}()"
            }
        }
    }

fun recordLaunchEntrypoint(entrypoint: String, box: Box, execute: Execute, strand: String = execute.strand)

    fun recordLaunchEntrypoint(entrypoint: String, box: Box, execute: Execute, strand: String = execute.strand) {
        val documentPath = documentPathFor(box) ?: return
        append(stateFor(documentPath)) { state, nowMs, nowIso ->
            state.launchedBoxes += box.id
            eventBase(
                state = state,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "launch",
                entrypoint = entrypoint
            ).apply {
                this["type"] = execute.type
                this["line"] = execute.line
                this["strand"] = strand
                this["singleLine"] = execute.singleLine
                this["code"] = execute.code
                this["properties"] = launchPropertySnapshot(box, codeOverride = execute.code)
            }
        }
    }

fun recordStopEntrypoint(entrypoint: String, box: Box)

    fun recordStopEntrypoint(entrypoint: String, box: Box) {
        val documentPath = documentPathFor(box) ?: return
        append(stateFor(documentPath)) { state, nowMs, nowIso ->
            val wasLaunched = state.launchedBoxes.contains(box.id)
            state.launchedBoxes -= box.id
            eventBase(
                state = state,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "stop",
                entrypoint = entrypoint
            ).apply {
                this["wasLaunched"] = wasLaunched
                this["properties"] = launchPropertySnapshot(box, codeOverride = null)
            }
        }
    }

fun recordTrackedPropertyChange(box: Box, property: String, oldValue: Any?, newValue: Any?)

    fun recordTrackedPropertyChange(box: Box, property: String, oldValue: Any?, newValue: Any?) {
        if (property !in launchedPropertyChangeProperties) return
        if (oldValue == newValue) return

        val documentPath = documentPathFor(box) ?: return
        val state = states[documentPath] ?: return

        append(state, guard = { it.launchedBoxes.contains(box.id) }) { s, nowMs, nowIso ->
            eventBase(
                state = s,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "property-change",
                entrypoint = "setPropertyRaw"
            ).apply {
                this["property"] = property
                this["oldValue"] = oldValue?.toString()
                this["newValue"] = newValue?.toString()
            }
        }
    }

fun checkpointOnMetadataChange(documentPath: String): Boolean

    fun checkpointOnMetadataChange(documentPath: String): Boolean {
        val normalizedDocumentPath = normalizeDocumentPath(documentPath)
        val state = states[normalizedDocumentPath] ?: return false
        val snapshot = synchronized(state.lock) {
            ensureLoaded(state)
            if (!state.dirty || state.entries.isEmpty()) return false
            state.entries.map { LinkedHashMap(it) }
        }

        val createBackup = synchronized(state.lock) {
            !state.previousProcessArchived
        }

        return try {
            val wrote = saveSnapshot(normalizedDocumentPath, snapshot, createBackup)
            if (wrote) {
                synchronized(state.lock) {
                    state.dirty = false
                    state.previousProcessArchived = true
                }
            }
            wrote
        } catch (t: Throwable) {
            lprintln(
                "warning,transcript",
                "Failed to checkpoint transcript for $normalizedDocumentPath: ${t.message}"
            )
            false
        }
    }

fun entriesForDocument(documentPath: String): List<LinkedHashMap<String, Any?>>

    @JvmStatic
    fun entriesForDocument(documentPath: String): List<LinkedHashMap<String, Any?>> {
        val normalizedDocumentPath = normalizeDocumentPath(documentPath)
        val state = states[normalizedDocumentPath]
        if (state != null) {
            synchronized(state.lock) {
                ensureLoaded(state)
                if (state.entries.isNotEmpty()) {
                    return state.entries.map { LinkedHashMap(it) }
                }
            }
        }
        return readEntriesFromDisk(normalizedDocumentPath)
    }

fun activitySummaryForDocument

body — 52 lines, from line 295
    fun activitySummaryForDocument(
        documentPath: String,
        untilMillis: Long = Long.MAX_VALUE
    ): ActivitySummary {
        val entries = entriesForDocument(documentPath)
            .filter { (it["timestampMillis"] as? Number)?.toLong()?.let { ts -> ts <= untilMillis } ?: true }

        var executions = 0
        var launches = 0
        data class MutableBoxActivity(
            var name: String = "",
            var executions: Int = 0,
            var launches: Int = 0
        )
        val perBox = linkedMapOf<String, MutableBoxActivity>()

        entries.forEach { entry ->
            val event = entry["event"]?.toString().orEmpty()
            if (event != "execute" && event != "execute-symbol" && event != "launch") return@forEach
            val box = mapOfStringAny(entry["box"]) ?: return@forEach
            val uid = box["id"]?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
            val name = box["name"]?.toString().orEmpty()
            val activity = perBox.getOrPut(uid) { MutableBoxActivity(name = name) }
            if (name.isNotBlank()) activity.name = name

            if (event == "launch") {
                launches++
                activity.launches++
            } else {
                executions++
                activity.executions++
            }
        }

        return ActivitySummary(
            fieldLaunchId = fieldLaunchId,
            fieldLaunchStartedAt = fieldLaunchStartedAt,
            executions = executions,
            launches = launches,
            perBox = perBox.map { (uid, activity) ->
                BoxActivitySummary(
                    uid = uid,
                    name = activity.name,
                    executions = activity.executions,
                    launches = activity.launches
                )
            }.sortedWith(
                compareByDescending<BoxActivitySummary> { it.executions + it.launches }
                    .thenBy { it.name.ifBlank { it.uid }.lowercase() }
            )
        )
    }

fun lineActivityForDocument

body — 43 lines, from line 348
    fun lineActivityForDocument(
        documentPath: String,
        uid: String,
        property: String,
        propertyText: String,
        maxEntries: Int = 4000
    ): TranscriptLineActivityResult {
        val liveLines = splitTranscriptLines(propertyText)
        val entries = archivedEntriesForDocument(documentPath)
            .filter { entry ->
                val event = entry["event"]?.toString().orEmpty()
                event == "execute" || event == "execute-symbol" || event == "launch"
            }
            .filter { entry ->
                val box = mapOfStringAny(entry["box"]) ?: return@filter false
                box["id"]?.toString() == uid && transcriptActivityProperty(entry) == property
            }
            .takeLast(maxEntries.coerceIn(1, 20000))

        val scores = linkedMapOf<Int, Pair<Double, Int>>()
        entries.forEachIndexed { index, entry ->
            val range = currentLineRangeForTranscriptEntry(entry, liveLines) ?: return@forEachIndexed
            val age = (entries.lastIndex - index).coerceAtLeast(0)
            val weight = 1.0 + 4.0 * kotlin.math.exp(-age.toDouble() / 60.0)
            for (line in range.first..range.last) {
                val existing = scores[line] ?: (0.0 to 0)
                scores[line] = (existing.first + weight) to (existing.second + 1)
            }
        }

        return TranscriptLineActivityResult(
            uid = uid,
            property = property,
            entryCount = entries.size,
            lines = scores.entries.map { (line, scoreCount) ->
                TranscriptLineActivity(
                    line = line,
                    score = scoreCount.first,
                    count = scoreCount.second
                )
            }
        )
    }

fun entriesFor(box: Box): List<LinkedHashMap<String, Any?>>

    @JvmStatic
    fun entriesFor(box: Box): List<LinkedHashMap<String, Any?>> {
        val documentPath = documentPathFor(box) ?: return emptyList()
        return entriesForDocument(documentPath)
    }

fun payloadForDocument(documentPath: String): LinkedHashMap<String, Any?>

    @JvmStatic
    fun payloadForDocument(documentPath: String): LinkedHashMap<String, Any?> {
        val entries = entriesForDocument(documentPath)
        return linkedMapOf(
            "version" to 1,
            "entries" to entries
        )
    }

fun payloadFor(box: Box): LinkedHashMap<String, Any?>

    @JvmStatic
    fun payloadFor(box: Box): LinkedHashMap<String, Any?> {
        val documentPath = documentPathFor(box) ?: return linkedMapOf(
            "version" to 1,
            "entries" to emptyList<Any?>()
        )
        return payloadForDocument(documentPath)
    }

fun htmlForDocument(documentPath: String): String

    @JvmStatic
    fun htmlForDocument(documentPath: String): String = renderTranscriptHtml(entriesForDocument(documentPath))

fun htmlFor(box: Box): String

body — 117 lines, from line 419
    @JvmStatic
    fun htmlFor(box: Box): String {
        val documentPath = documentPathFor(box) ?: return ""
        return htmlForDocument(documentPath)
    }

    private fun stateFor(documentPath: String): DocumentState =
        states.computeIfAbsent(normalizeDocumentPath(documentPath)) { DocumentState(documentPath = it) }

    private fun append(
        state: DocumentState,
        guard: ((DocumentState) -> Boolean)? = null,
        builder: (DocumentState, Long, String) -> LinkedHashMap<String, Any?>
    ) {
        synchronized(state.lock) {
            ensureLoaded(state)
            if (guard != null && !guard(state)) return

            val nowMs = System.currentTimeMillis()
            val nowIso = Instant.ofEpochMilli(nowMs).toString()
            val entry = builder(state, nowMs, nowIso)
            entry["index"] = state.entries.size
            state.entries += entry
            state.dirty = true
        }
    }

    private fun ensureLoaded(state: DocumentState) {
        if (state.loaded) return
        state.loaded = true
        // Start fresh per Field process launch.
        // We intentionally do not load any previous transcript from disk.
    }

    private fun saveSnapshot(
        documentPath: String,
        snapshot: List<LinkedHashMap<String, Any?>>,
        createBackup: Boolean
    ): Boolean {
        val file = transcriptFile(documentPath)
        file.parentFile?.mkdirs()
        val payload = linkedMapOf<String, Any?>(
            "version" to 1,
            "entries" to snapshot
        )
        val text = Edn.write(payload, Edn.CustomTypes())
        return file.safeSave(text, createBackup, maxBackups = 10)
    }

    private fun readEntriesFromDisk(documentPath: String): List<LinkedHashMap<String, Any?>> {
        val file = transcriptFile(documentPath)
        if (!file.exists()) return emptyList()
        return readEntriesFromFile(file, documentPath)
    }

    private fun archivedEntriesForDocument(documentPath: String): List<LinkedHashMap<String, Any?>> {
        val normalizedDocumentPath = normalizeDocumentPath(documentPath)
        val byKey = linkedMapOf<String, LinkedHashMap<String, Any?>>()
        transcriptArchiveFiles(normalizedDocumentPath).forEach { file ->
            readEntriesFromFile(file, normalizedDocumentPath).forEach { entry ->
                byKey[transcriptEntryArchiveKey(entry)] = entry
            }
        }
        val state = states[normalizedDocumentPath]
        if (state != null) {
            synchronized(state.lock) {
                ensureLoaded(state)
                state.entries.forEach { entry ->
                    byKey[transcriptEntryArchiveKey(entry)] = LinkedHashMap(entry)
                }
            }
        }
        return byKey.values.sortedWith(
            compareBy<LinkedHashMap<String, Any?>>(
                { (it["timestampMillis"] as? Number)?.toLong() ?: Long.MIN_VALUE },
                { (it["index"] as? Number)?.toInt() ?: -1 }
            )
        )
    }

    private fun transcriptArchiveFiles(documentPath: String): List<File> {
        val current = transcriptFile(documentPath)
        val backups = File(current.parentFile, "backups")
            .listFiles()
            ?.filter { it.isFile && it.name.startsWith("transcript_backup_") && it.name.endsWith(".edn") }
            ?.sortedBy { it.name }
            .orEmpty()
        return backups + listOfNotNull(current.takeIf { it.exists() })
    }

    private fun transcriptEntryArchiveKey(entry: Map<String, Any?>): String {
        val fieldLaunchId = entry["fieldLaunchId"]?.toString()
        val index = entry["index"]?.toString()
        if (!fieldLaunchId.isNullOrBlank() && !index.isNullOrBlank()) return "$fieldLaunchId#$index"
        val box = mapOfStringAny(entry["box"])
        return listOf(
            entry["timestampMillis"]?.toString().orEmpty(),
            entry["event"]?.toString().orEmpty(),
            box?.get("id")?.toString().orEmpty(),
            entry["line"]?.toString().orEmpty(),
            entry["code"]?.toString()?.hashCode()?.toString().orEmpty()
        ).joinToString("#")
    }

    private fun readEntriesFromFile(file: File, documentPathForLog: String): List<LinkedHashMap<String, Any?>> {
        return runCatching {
            val root = Edn.read(file.readText()) as? Map<*, *> ?: return@runCatching emptyList()
            val entries = root["entries"] as? List<*> ?: return@runCatching emptyList()
            entries.mapNotNull { entry ->
                val map = mapOfStringAny(entry) ?: return@mapNotNull null
                LinkedHashMap(map)
            }
        }.getOrElse {
            lprintln("warning,transcript", "Failed to read transcript from $documentPathForLog (${file.name}): ${it.message}")
            emptyList()
        }
    }

fun recordAiProvisionalAccepted

body — 412 lines, from line 537
    fun recordAiProvisionalAccepted(
        box: Box,
        property: String,
        acceptedValue: String,
        archiveMarkdown: String?
    ) {
        val documentPath = documentPathFor(box) ?: return
        append(stateFor(documentPath)) { state, nowMs, nowIso ->
            eventBase(
                state = state,
                box = box,
                nowMs = nowMs,
                nowIso = nowIso,
                event = "ai-provisional-accept",
                entrypoint = "provisional-accept"
            ).apply {
                this["property"] = property
                this["acceptedValue"] = acceptedValue
                archiveMarkdown?.takeIf { it.isNotBlank() }?.let {
                    this["archiveMarkdown"] = it
                }
            }
        }
    }

    private fun currentLineRangeForTranscriptEntry(
        entry: Map<String, Any?>,
        liveLines: List<String>
    ): IntRange? {
        val code = entry["code"]?.toString().orEmpty()
        val snippetLines = splitTranscriptLines(code).ifEmpty { listOf("") }
        val recordedStart = ((entry["line"].asInt() ?: 0) + 1).coerceAtLeast(1)
        val matchedStart = findSnippetStartLine(liveLines, snippetLines, recordedStart)
        val start = matchedStart ?: recordedStart
        val end = (start + snippetLines.size - 1).coerceAtLeast(start)
        val boundedStart = start.coerceAtLeast(1)
        val boundedEnd = if (liveLines.isEmpty()) end else end.coerceIn(boundedStart, liveLines.size)
        return boundedStart..boundedEnd
    }

    private fun findSnippetStartLine(
        liveLines: List<String>,
        snippetLines: List<String>,
        preferredStart: Int
    ): Int? {
        if (liveLines.isEmpty() || snippetLines.isEmpty()) return null
        val normalizedSnippet = snippetLines.map { it.trimEnd() }
        fun matchesAt(startIndex: Int): Boolean {
            if (startIndex < 0 || startIndex + normalizedSnippet.size > liveLines.size) return false
            return normalizedSnippet.indices.all { offset ->
                liveLines[startIndex + offset].trimEnd() == normalizedSnippet[offset]
            }
        }

        val preferredIndex = (preferredStart - 1).coerceAtLeast(0)
        if (matchesAt(preferredIndex)) return preferredIndex + 1

        var best: Int? = null
        var bestDistance = Int.MAX_VALUE
        val maxStart = liveLines.size - normalizedSnippet.size
        for (candidate in 0..maxStart.coerceAtLeast(0)) {
            if (!matchesAt(candidate)) continue
            val distance = kotlin.math.abs(candidate - preferredIndex)
            if (distance < bestDistance) {
                best = candidate + 1
                bestDistance = distance
            }
        }
        return best
    }

    private fun splitTranscriptLines(text: String): List<String> {
        val normalized = text.replace("\r\n", "\n").replace("\r", "\n")
        return if (normalized.isEmpty()) emptyList() else normalized.split('\n')
    }

    private fun renderTranscriptHtml(snapshot: List<LinkedHashMap<String, Any?>>): String {
        if (snapshot.isEmpty()) return ""
        return buildString {
            append("<div class=\"field-transcript\" style=\"display:flex;flex-direction:column;gap:18px;\">")
            snapshot.forEach { entry ->
                append(renderTranscriptEntryHtml(entry))
            }
            append("</div>")
        }
    }

    private fun renderTranscriptEntryHtml(entry: Map<String, Any?>): String {
        val event = entry["event"]?.toString().orEmpty()
        val box = mapOfStringAny(entry["box"])
        val primaryProperty = transcriptPrimaryProperty(entry)
        val line = entry["line"].asInt()?.takeIf { it > 0 }
        val heading = buildString {
            append(transcriptEventLabel(event))
            box?.let {
                append(" · ")
                append(boxLinkHtml(it, property = primaryProperty, line = line))
            }
            if (!primaryProperty.isNullOrBlank()) {
                append(" <span style=\"font-size:0.72em;font-weight:400;color:rgba(255,255,255,0.56);\">")
                append(escapeHtml(primaryProperty))
                append("</span>")
            }
        }

        return buildString {
            append(
                """
                <section class="field-transcript-entry" style="padding:16px 18px;border-radius:12px;border:1px solid rgba(255,255,255,0.08);background:rgba(17,20,24,0.9);box-shadow:0 8px 30px rgba(0,0,0,0.18);">
                  <h2 style="margin:0 0 10px 0;font-size:18px;line-height:1.25;font-weight:560;color:#f6fbff;">$heading</h2>
                """.trimIndent()
            )
            append(renderTranscriptFacts(entry, box))
            append(renderTranscriptBody(entry, box, primaryProperty))
            append("</section>")
        }
    }

    private fun renderTranscriptFacts(entry: Map<String, Any?>, box: Map<String, Any?>?): String {
        val facts = linkedMapOf<String, String>()
        entry["timestamp"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Time"] = it }
        entry["entrypoint"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Entrypoint"] = it }
        entry["language"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Language"] = it }
        entry["document"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Document"] = it.substringAfterLast(File.separatorChar) }
        entry["strand"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Strand"] = it }
        entry["line"].asInt()?.takeIf { it > 0 }?.let { facts["Line"] = it.toString() }
        entry["symbol"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Symbol"] = it }
        entry["singleLine"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Single Line"] = it }
        entry["wasLaunched"]?.toString()?.takeIf { it.isNotBlank() }?.let { facts["Was Launched"] = it }

        box?.get("frame")?.let { frameAny ->
            val frame = mapOfStringAny(frameAny)
            if (frame != null) {
                val x = frame["x"]?.toString()
                val y = frame["y"]?.toString()
                val w = frame["w"]?.toString()
                val h = frame["h"]?.toString()
                if (x != null && y != null && w != null && h != null) {
                    facts["Frame"] = "($x, $y) $w×$h"
                }
            }
        }

        if (facts.isEmpty()) return ""

        return buildString {
            append("<div style=\"display:flex;flex-wrap:wrap;gap:8px 16px;margin:0 0 12px 0;font-size:12px;line-height:1.45;color:rgba(255,255,255,0.7);\">")
            facts.forEach { (label, value) ->
                append("<span><span style=\"color:rgba(255,255,255,0.46);text-transform:uppercase;letter-spacing:0.06em;\">")
                append(escapeHtml(label))
                append("</span> <span style=\"color:#edf5fb;\">")
                append(escapeHtml(value))
                append("</span></span>")
            }
            append("</div>")
        }
    }

    private fun renderTranscriptBody(
        entry: Map<String, Any?>,
        box: Map<String, Any?>?,
        primaryProperty: String?
    ): String {
        return buildString {
            val code = entry["code"]?.toString()
            if (!code.isNullOrBlank()) {
                append(renderCodeBlock(
                    title = when (entry["event"]?.toString()) {
                        "execute-symbol" -> "Expanded Code"
                        else -> "Code"
                    },
                    box = box,
                    property = primaryProperty,
                    content = code
                ))
            }

            if (entry["event"] == "property-change") {
                val property = entry["property"]?.toString()?.ifBlank { primaryProperty } ?: primaryProperty
                entry["oldValue"]?.toString()?.let {
                    append(renderCodeBlock("Previous Value", box, property, it))
                }
                entry["newValue"]?.toString()?.let {
                    append(renderCodeBlock("New Value", box, property, it))
                }
            }

            if (entry["event"] == "ai-provisional-accept") {
                val property = entry["property"]?.toString()?.ifBlank { primaryProperty } ?: primaryProperty
                entry["acceptedValue"]?.toString()?.let {
                    append(renderCodeBlock("Accepted Value", box, property, it))
                }
                entry["archiveMarkdown"]?.toString()?.takeIf { it.isNotBlank() }?.let {
                    append(renderCodeBlock("AI Archive Markdown", box, property, it))
                }
            }

            val properties = mapOfStringAny(entry["properties"])
            if (!properties.isNullOrEmpty()) {
                append(renderPropertySnapshot(properties, box))
            }
        }
    }

    private fun renderPropertySnapshot(properties: Map<String, Any?>, box: Map<String, Any?>?): String {
        val orderedKeys = linkedSetOf<String>().apply {
            addAll(launchSnapshotProperties)
            addAll(properties.keys)
        }
        val sections = orderedKeys.mapNotNull { property ->
            val value = properties[property]?.toString() ?: return@mapNotNull null
            renderCodeBlock("Saved ${property.replaceFirstChar { it.uppercase() }}", box, property, value)
        }
        if (sections.isEmpty()) return ""
        return sections.joinToString("")
    }

    private fun renderCodeBlock(
        title: String,
        box: Map<String, Any?>?,
        property: String?,
        content: String
    ): String {
        val heading = if (box != null && !property.isNullOrBlank()) {
            boxLinkHtml(box, property = property, label = title)
        } else {
            escapeHtml(title)
        }
        return """
            <div style="margin:14px 0 0 0;">
              <div style="margin:0 0 6px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:rgba(255,255,255,0.5);">$heading</div>
              <pre style="margin:0;padding:12px 14px;border-radius:10px;background:rgba(0,0,0,0.32);border:1px solid rgba(255,255,255,0.06);color:#edf5fb;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;"><code>${escapeHtml(content)}</code></pre>
            </div>
        """.trimIndent()
    }

    private fun transcriptEventLabel(event: String): String = when (event) {
        "execute" -> "Execute"
        "execute-symbol" -> "Execute Symbol"
        "launch" -> "Launch"
        "stop" -> "Stop"
        "property-change" -> "Property Change"
        "ai-provisional-accept" -> "Accepted AI Proposal"
        else -> event.replace('-', ' ').replaceFirstChar { it.uppercase() }
    }

    private fun transcriptPrimaryProperty(entry: Map<String, Any?>): String? {
        val property = entry["property"]?.toString()?.takeIf { it.isNotBlank() }
        if (property != null) return property

        val type = entry["type"]?.toString()?.takeIf { it.isNotBlank() } ?: return null
        return when (type) {
            "code", "vertex", "fragment", "stackSL", "rectSL", "task", "mesh", "compute",
            "vertex_s", "fragment_s", "vertex_fragment", "task_s", "mesh_s", "compute_s",
            "raygen", "miss", "closesthit", "anyhit", "intersection", "callable",
            "raygen_s", "miss_s", "closesthit_s", "anyhit_s", "intersection_s", "callable_s",
            "preamble", "introduction" -> type
            else -> null
        }
    }

    private fun transcriptActivityProperty(entry: Map<String, Any?>): String {
        val explicit = transcriptPrimaryProperty(entry)
        if (!explicit.isNullOrBlank()) return explicit
        return when (entry["type"]?.toString()) {
            "fragment", "all", "launch" -> "code"
            else -> "code"
        }
    }

    private fun boxLinkHtml(
        box: Map<String, Any?>,
        property: String? = null,
        line: Int? = null,
        label: String? = null
    ): String {
        val uid = box["id"]?.toString()?.takeIf { it.isNotBlank() } ?: return escapeHtml(label ?: "box")
        val boxName = box["name"]?.toString()?.takeIf { it.isNotBlank() } ?: uid
        val linkLabel = label ?: boxName
        val query = linkedMapOf<String, String>().apply {
            property?.takeIf { it.isNotBlank() }?.let { this["property"] = it }
            line?.let {
                this["scrollToLine"] = it.toString()
                this["highlightLine"] = it.toString()
            }
        }
        val href = buildString {
            append("#box:")
            append(urlEncode(uid))
            if (query.isNotEmpty()) {
                append("?")
                append(query.entries.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" })
            }
        }
        return buildString {
            append("<a href=\"")
            append(href)
            append("\" data-box-uid=\"")
            append(escapeHtml(uid))
            append("\"")
            property?.takeIf { it.isNotBlank() }?.let {
                append(" data-box-property=\"")
                append(escapeHtml(it))
                append("\"")
            }
            line?.let {
                append(" data-box-scroll-to-line=\"")
                append(it)
                append("\" data-box-highlight-line=\"")
                append(it)
                append("\"")
            }
            append(" style=\"color:rgba(var(--field-cyan-rgb,72,201,255),0.98);text-decoration:none;cursor:pointer;\">")
            append(escapeHtml(linkLabel))
            append("</a>")
        }
    }

    private fun mapOfStringAny(value: Any?): Map<String, Any?>? {
        val map = value as? Map<*, *> ?: return null
        val out = linkedMapOf<String, Any?>()
        map.forEach { (key, entryValue) ->
            if (key is String) out[key] = entryValue
        }
        return out
    }

    private fun Any?.asInt(): Int? = when (this) {
        is Number -> this.toInt()
        is String -> this.toIntOrNull()
        else -> null
    }

    private fun urlEncode(value: String): String =
        URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")

    private fun escapeHtml(text: String): String =
        text.replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace("\"", "&quot;")
            .replace("'", "&#39;")

    private fun eventBase(
        state: DocumentState,
        box: Box,
        nowMs: Long,
        nowIso: String,
        event: String,
        entrypoint: String
    ): LinkedHashMap<String, Any?> {
        return linkedMapOf(
            "timestamp" to nowIso,
            "timestampMillis" to nowMs,
            "event" to event,
            "entrypoint" to entrypoint,
            "document" to state.documentPath,
            "fieldLaunchId" to fieldLaunchId,
            "fieldLaunchStartedAt" to fieldLaunchStartedAt,
            "box" to boxDescriptor(box),
            "language" to box.ke.languageName
        )
    }

    private fun boxDescriptor(box: Box): Map<String, Any?> {
        val frame = runCatching { box.frame }.getOrNull()
        return linkedMapOf(
            "id" to box.id,
            "name" to (box.getPropertyRaw("name", "")?.toString() ?: ""),
            "frame" to if (frame != null) {
                linkedMapOf(
                    "x" to frame.x,
                    "y" to frame.y,
                    "w" to frame.w,
                    "h" to frame.h
                )
            } else {
                null
            }
        )
    }

    private fun launchPropertySnapshot(box: Box, codeOverride: String?): Map<String, Any?> {
        val out = linkedMapOf<String, Any?>()
        launchSnapshotProperties.forEach { property ->
            val value = if (property == "code" && codeOverride != null) {
                codeOverride
            } else {
                box.getPropertyRaw(property)
            }
            if (value != null) {
                out[property] = value.toString()
            }
        }
        return out
    }

    private fun transcriptFile(documentPath: String): File =
        File(documentPath, SavingContext.TRANSCRIPT_EDN)

    private fun normalizeDocumentPath(documentPath: String): String =
        File(documentPath).absolutePath

    private fun documentPathFor(box: Box): String? {
        val path = if (box.isInsideInitialized()) {
            box.inside.filename
        } else {
            runCatching { thisSessionInfo.get()?.filename }.getOrNull()
        }
        return path?.let(::normalizeDocumentPath)
    }
}