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("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
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)
}
}