Skip to content

saving.kt

src/jvmMain/kotlin/afield/document/persistence/saving.kt · 2167 lines · cited by What a document is on disk, Keyframes: capturing, persisting and replaying live state

package and imports — 92 lines, from line 1
@file:OptIn(ExperimentalSerializationApi::class, kotlinx.coroutines.DelicateCoroutinesApi::class)

package afield.document.persistence

import afield.ai.conversation.images
import afield.app.protocol.AgentDocumentSnapshot
import afield.app.protocol.Metadata
import afield.app.server.Server
import afield.app.server.documents
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.nested
import afield.document.graph.nestedIn
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.Mark
import afield.ui.notation.long
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.output.history
import afield.ui.output.markdown
import afield.ui.output.md
import afield.ui.output.templates.templates
import afield.ui.output.transcript
import afield.ui.output.tree

import afield.app.notifications.notify
import afield.app.protocol.HistoryChanged
import afield.app.protocol.InitialBoxFrame
import afield.app.protocol.Message
import afield.app.protocol.Notify
import afield.app.protocol.Rect
import afield.app.protocol._send
import afield.app.protocol.computeIfAbsent
import afield.app.protocol.payload
import afield.app.protocol.put
import afield.app.server.ReadOnlyDocuments
import afield.app.server.SessionInformation
import afield.app.server.thisSessionInfo
import afield.audio.synth.*
import afield.data.table.get
import afield.data.table.plus
import afield.data.table.ref
import afield.document.graph.Box
import afield.document.graph.METAROOT_ID
import afield.document.graph.box
import afield.document.graph.isTrue
import afield.document.graph.reconcileNestingEdges
import afield.document.history.HistoryManager
import afield.document.history.HistoryMetrics
import afield.document.history.HistoryScreenshotCapture
import afield.document.workspace.metadata
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.execution.plus
import afield.runtime.lifecycle.Runloop
import afield.runtime.lifecycle.inMain
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.plus
import afield.tools.automation.FlightRecorder
import afield.tools.automation.OperationTranscript
import afield.tools.docs.format
import afield.ui.output.templates.filter
import afield.ui.visualization.h

import afield.ai.completion.Ai4AutoBoxNamer
import afield.app.diagnostics.logging.SaveReloadLog
import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

Reads and writes a document as metadata.edn plus sidecar files, and keeps backups of what it replaces.

SavingContext is the canonical reader and writer of the version 2 document format: it saves a set of boxes to metadata.edn, writing large text properties such as code or fragment to <uid>_<property>_.<ext> sidecars, and loads a document folder (or EDN text) back into boxes. File.safeSave is the file write underneath it, which keeps the previous version in a backups/ directory and can write atomically through a temp file and a rename.

data class Box_transfer(val id: String, val properties: Map<String, Pair<String, JsonElement>>)

@Serializable
data class Box_transfer(val id: String, val properties: Map<String, Pair<String, JsonElement>>)

data class BoxTransferEdn(val id: String, val properties: Map<String, Any?>)

data class InlineFileSnapshot

One sidecar-backed property as stored inline in a document's metadata.edn.

data class InlineFileSnapshot(
    val boxId: String,
    val property: String,
    val path: String,
    val inline: String,
)

data class InlineMetadata(val paths: List<String>, val metadataEdn: String)

A document's metadata.edn as Field's codec would write it, with the inline fallbacks refreshed.

paths are the sidecars that were folded back in, in document order; metadataEdn is the exact text SavingContext.refreshInlineFilesFromDisk would save.

private data class BoxRef(val id: String)
private data class FileRef(val path: String)

internal const val INLINE_SIDECAR_LIMIT = 256 * 1024

The size above which a sidecar is referenced from metadata.edn (#afield/file) instead of inlined into it as well (#afield/file-inline).

The inline copy is a fallback for a document whose loose files went missing, and it is worth its bytes for source. It is not worth them for an AI4 chat history — megabytes of base64 images — which was being rewritten into metadata.edn on every save and backed up twice (as the sidecar and inside metadata.edn): five turns of one conversation left 127 MB of backups. A sidecar loads from its file either way.

internal fun referencesSidecar(property: String, contents: String): Boolean

Whether property's sidecar, holding contents, is referenced from metadata.edn rather than inlined.

internal fun referencesSidecar(property: String, contents: String): Boolean =
    property == afield.ai.conversation.AiBoxConversation.PROPERTY || property == afield.ai.conversation.AiSessionTree.PROPERTY ||
        contents.length > INLINE_SIDECAR_LIMIT

properties as metadata.edn carries them: large sidecars as references, everything else as is.

private fun referencingLargeSidecars(properties: Map<String, Any?>): Map<String, Any?> =
    if (properties.values.none { it is FileInline }) properties
    else properties.mapValuesTo(LinkedHashMap()) { (name, value) ->
        if (value is FileInline && referencesSidecar(name, value.inline)) FileRef(value.path) else value
    }

internal data class FileInline(val path: String, val inline: String)

class SavingContext

class SavingContext {

    private object SkipRef

    private data class PartialSaveBlock(
        val existingCount: Int,
        val snapshotCount: Int,
        val missingCount: Int,
        val missingPreview: String
    )

    private data class MarkdownBlockProperty(
        val name: String,
        val language: String,
        val text: String,
        val path: String?
    )

    private data class ExtractedNarrative(
        val property: String,
        val text: String,
        val tags: List<String>
    )

fun exportEdnPackage(boxes: Set<Box>, directory: String): String

    fun exportEdnPackage(boxes: Set<Box>, directory: String): String {
        val transfers = toTransferInline(boxes, directory)
        val payloadBoxes = transfers.map { transfer ->
            linkedMapOf<String, Any?>(
                "id" to transfer.id,
                "properties" to transfer.properties
            )
        }
        val payload = linkedMapOf<String, Any?>(
            "version" to 2,
            "boxes" to payloadBoxes,
            "persistentProperties" to Box.persistentPropertiesFor(directory).toList()
        )
        return Edn.write(payload, ednCustom)
    }

fun exportDocumentMarkdown(boxes: Set<Box>, directory: String): String

    fun exportDocumentMarkdown(boxes: Set<Box>, directory: String): String {
        val transfers = toTransferInline(boxes, directory)
        val snapshot = SaveSnapshot(
            transfers = transfers,
            persistentProperties = Box.persistentPropertiesFor(directory).toList(),
            fileWatchManager = null,
            propertyCount = transfers.sumOf { it.properties.size },
            sidecarCount = transfers.sumOf { transfer -> transfer.properties.values.count { it is FileInline } },
            sidecarChars = transfers.sumOf { transfer ->
                transfer.properties.values.sumOf { (it as? FileInline)?.inline?.length?.toLong() ?: 0L }
            }
        )
        return buildDocumentMarkdown(snapshot, File(directory).name)
    }

fun listTemplates(dir: String): List<String>

    fun listTemplates(dir: String): List<String> {
        val directory = File(dir)
        if (!directory.exists()) return emptyList()

        val files = directory.listFiles() ?: return emptyList()
        val result = mutableListOf<String>()

        // Add subdirectories containing metadata files
        files.filter { file ->
            file.isDirectory && (File(file, METADATA_EDN).exists() || File(file, METADATA_JSON).exists())
        }.mapTo(result) { it.name }

        // Add standalone metadata.edn files (but not the main metadata.edn)
        files.filter { file ->
            file.isFile && file.name.endsWith(".edn") && file.name != METADATA_EDN
        }.mapTo(result) { it.nameWithoutExtension }

        return result
    }

fun cloneInto(dir: String): Set<Box>

    @OptIn(ExperimentalUuidApi::class)
    fun cloneInto(dir: String): Set<Box> {
        val seen = mutableMapOf<String, String>("root" to "root")
        return load(dir, { id ->
            val next = seen.computeIfAbsent(id) { Uuid.random().toString() }
            next
        }, upgradeOnLoad = false)
    }

fun cloneTemplateInto(path: String): Set<Box>

cloneInto for inserting a template into an open document. The template's own root and metaroot entries are left out: a template that is a whole document (a workspace document, or a bundled template folder saved with its root) would otherwise write its root's frame, children, drawing and bookmarks over the receiving document's root and add a stray copy of its metaroot. Its top-level boxes still name root as their parent, which is the receiving document's root. A metadata.json template has no such entries to drop and goes through cloneInto.

    @OptIn(ExperimentalUuidApi::class)
    fun cloneTemplateInto(path: String): Set<Box> {
        val file = File(path)
        val (transfers, directory) = when {
            file.isFile && file.name.endsWith(".edn") -> {
                val directory = file.absoluteFile.parentFile?.absolutePath ?: file.absolutePath
                readEdnTransfersFromString(file.readText(), recordInto = directory) to directory
            }
            File(file, METADATA_EDN).exists() -> readEdnTransfers(file.path) to file.path
            else -> return cloneInto(path)
        }
        val seen = mutableMapOf("root" to "root", METAROOT_ID to METAROOT_ID)
        return instantiateTransfers(
            transfers.filterNot { it.id == "root" || it.id == METAROOT_ID },
            directory,
            { id -> seen.computeIfAbsent(id) { Uuid.random().toString() } },
            createBoxes = true
        )
    }

var altered = false

    private fun withoutMetarootTopology(name: String, value: Any?): Any? {
        if (name != "parents" && name != "children") return value
        return when (value) {
            is Set<*> -> value.filterNot { it is Box && (it.id == METAROOT_ID || afield.document.workspace.WorkspaceProjectScopes.isProjectRoot(it)) }.toCollection(LinkedHashSet())
            is List<*> -> value.filterNot { it is Box && (it.id == METAROOT_ID || afield.document.workspace.WorkspaceProjectScopes.isProjectRoot(it)) }
            else -> value
        }
    }

fun toTransfer(boxes: Set<Box>, directory: String): List<BoxTransferEdn>

Serializes each box's persistent properties for save. Only properties whose name matches one of Box.persistentPropertiesFor directory (regex-matched) are written; others are dropped. Box.ioNeverPersistedProperties then vetoes what survived, which is how Monaco view state stays off disk despite mergePersistentProperties re-adding ".*_editorState" from every older metadata.edn this process loads.

Root-box persistence relies on this path: callers that save (see allBoxes.add(info.root) in Server.kt before SavingContext().save(...)) explicitly add the root box to the save set, so its persistent root properties (e.g. "bookmarks", which is listed in Box.ioPersistentProperties) are serialized here. On load, box("root") resolves back to SessionInformation.root (Boxes.kt) and setPropertyRaw restores the property onto it.

body — 53 lines, from line 289
    fun toTransfer(boxes: Set<Box>, directory: String): List<BoxTransferEdn> {
        val persistent = Box.persistentPropertiesFor(directory)
        val patterns = persistent.map { it.toRegex() }
        return boxes.map { box ->
            val properties = LinkedHashMap<String, Any?>()
            box.properties.forEach { (name, rawValue) ->
                if (!patterns.any { name.matches(it) }) return@forEach
                if (Box.isNeverPersisted(name)) return@forEach
                val value = withoutMetarootTopology(name, rawValue)
                if (value == null) return@forEach

                val shouldUseSidecar = box.id != "root" && Box.ioSeparateProperties.containsKey(name)
                if (shouldUseSidecar) {
                    val extension = Box.ioSeparateProperties[name]!!
                    val contents = value as? String ?: value.toString()
                    properties[name] = toFile(box.id, name, extension, directory, contents)
                } else {
                    val normalized = normalizeValue(value)
                    if (normalized != null) {
                        properties[name] = normalized
                    }
                }
            }
            BoxTransferEdn(box.id, properties)
        }
    }

    private fun toTransferInline(boxes: Set<Box>, directory: String): List<BoxTransferEdn> {
        val persistent = Box.persistentPropertiesFor(directory)
        val patterns = persistent.map { it.toRegex() }
        return boxes.map { box ->
            val properties = LinkedHashMap<String, Any?>()
            box.properties.forEach { (name, rawValue) ->
                if (!patterns.any { name.matches(it) }) return@forEach
                if (Box.isNeverPersisted(name)) return@forEach
                val value = withoutMetarootTopology(name, rawValue)
                if (value == null) return@forEach

                val shouldUseSidecar = box.id != "root" && Box.ioSeparateProperties.containsKey(name)
                if (shouldUseSidecar) {
                    val extension = Box.ioSeparateProperties[name]!!
                    val contents = value as? String ?: value.toString()
                    properties[name] = FileInline(filenameFor(box.id, name, extension), contents)
                } else {
                    val normalized = normalizeInlineValue(value, directory)
                    if (normalized != null) {
                        properties[name] = normalized
                    }
                }
            }
            BoxTransferEdn(box.id, properties)
        }
    }

fun checkDifference

body — 88 lines, from line 343
    fun checkDifference(
        p: String,
        contents: String,
        createBackup: Boolean = true,
        maxBackups: Int? = null
    ): Boolean {
        if (!File(p).exists()) {
            File(p).safeSave(contents, createBackup, maxBackups)
            return true
        } else {
            try {
                return File(p).safeSave(contents, createBackup, maxBackups)
            } catch (ex: Exception) {
                File(p).writeText(contents)
                return true
            }
        }
    }

    private fun toFile(
        id: String,
        name: String,
        extension: String,
        directory: String,
        contents: String
    ): FileInline {
        val path = filenameFor(id, name, extension)
        val cd = checkDifference(File(directory, path).path, contents, maxBackups = 10)
        altered = altered || cd
        return FileInline(path, contents)
    }

    private fun normalizeValue(value: Any?): Any? = when (value) {
        null -> null
        is Rect -> value
        is String -> value
        is Number -> value
        is Boolean -> value
        is Box -> BoxRef(value.id)
        is BoxRef -> value
        is FileRef -> value
        is FileInline -> value
        is List<*> -> value.map { normalizeValue(it) }
        is Set<*> -> value.mapTo(LinkedHashSet()) { normalizeValue(it) }
        is Map<*, *> -> {
            val mapped = LinkedHashMap<Any?, Any?>()
            for ((k, v) in value) {
                mapped[normalizeValue(k)] = normalizeValue(v)
            }
            mapped
        }

        else -> {
            lprintln("warning,save", "unhandled class $value ${value.javaClass}")
            null
        }
    }

    private fun normalizeInlineValue(value: Any?, directory: String): Any? = when (value) {
        null -> null
        is Rect -> value
        is String -> value
        is Number -> value
        is Boolean -> value
        is Box -> BoxRef(value.id)
        is BoxRef -> value
        is FileRef -> {
            val file = File(directory, value.path)
            val inline = if (file.exists()) file.readText() else ""
            FileInline(value.path, inline)
        }

        is FileInline -> value
        is List<*> -> value.map { normalizeInlineValue(it, directory) }
        is Set<*> -> value.mapTo(LinkedHashSet()) { normalizeInlineValue(it, directory) }
        is Map<*, *> -> {
            val mapped = LinkedHashMap<Any?, Any?>()
            for ((k, v) in value) {
                mapped[normalizeInlineValue(k, directory)] = normalizeInlineValue(v, directory)
            }
            mapped
        }

        else -> {
            lprintln("warning,save", "unhandled class $value ${value.javaClass}")
            null
        }
    }

fun save

Save boxes to disk, optimized to minimize main thread blocking.

This method works in two phases: 1. Snapshot phase (main thread): Quickly capture all box properties into memory structures 2. I/O phase (background thread): Write files, create backups, and perform git operations

This approach prevents frame hiccups in VR by moving heavy I/O operations off the main thread.

Returns without writing anything when ReadOnlyDocuments blocks fn. The gate is here and not only at the callers because this function has eight of them, and two — Ai4AutoBoxNamer and FileWatchManager — reach disk without passing the autosave loop's writesToDisk check. onComplete still runs: callers latch on it, and one that never fires is a hang rather than a refusal.

inline writes on the calling thread instead of the misc pool. Only the shutdown flush (flushPendingDocumentSaves) wants that: it runs while the misc-drain shutdown hook is closing that pool, so work handed to it could be rejected.

    fun save(
        fn: String,
        boxes: Set<Box>,
        message: String? = null,
        onComplete: (() -> Unit)? = null,
        inline: Boolean = false,
    ) {
        if (ReadOnlyDocuments.blocksWritesTo(fn)) {
            SaveReloadLog.log("save-refused-read-only", "doc=$fn boxes=${boxes.size} message=${message ?: ""}")
            lprintln("info,saving", "read-only: not saving $fn")
            onComplete?.invoke()
            return
        }
        // Phase 1: Snapshot in main thread - capture all data we need
        val snapshotStartedAt = System.nanoTime()
        val snapshot = snapshotBoxes(boxes, fn)
        val snapshotMs = SaveReloadLog.logTiming(
            "save-snapshot",
            snapshotStartedAt,
            "doc=$fn boxes=${snapshot.transfers.size} properties=${snapshot.propertyCount} sidecars=${snapshot.sidecarCount} sidecarChars=${snapshot.sidecarChars}"
        )
        if (snapshotMs >= 250L) {
            lprintln(
                "warning,save",
                "slow save snapshot doc=$fn ms=$snapshotMs boxes=${snapshot.transfers.size} properties=${snapshot.propertyCount} sidecars=${snapshot.sidecarCount}"
            )
        }

        // Phase 2: Perform I/O operations in background thread
        saveInBackground(snapshot, fn, boxes, message, onComplete, System.nanoTime(), inline)
    }

Phase 1: Snapshot all box properties in the main thread. This is a quick operation that just reads properties without any I/O.

source — 48 lines, from line 487
    private fun snapshotBoxes(boxes: Set<Box>, directory: String): SaveSnapshot {
        val persistent = Box.persistentPropertiesFor(directory)
        val patterns = persistent.map { it.toRegex() }
        var propertyCount = 0
        var sidecarCount = 0
        var sidecarChars = 0L
        val boxSnapshots = boxes.map { box ->
            val properties = LinkedHashMap<String, Any?>()
            box.properties.forEach { (name, value) ->
                if (!patterns.any { name.matches(it) }) return@forEach
                if (Box.isNeverPersisted(name)) return@forEach
                if (value == null) return@forEach

                // For separate properties, capture the content now
                val shouldUseSidecar = box.id != "root" && Box.ioSeparateProperties.containsKey(name)
                if (shouldUseSidecar) {
                    val extension = Box.ioSeparateProperties[name]!!
                    val contents = value as? String ?: value.toString()
                    properties[name] = FileInline(filenameFor(box.id, name, extension), contents)
                    propertyCount++
                    sidecarCount++
                    sidecarChars += contents.length.toLong()
                } else {
                    val normalized = normalizeValue(value)
                    if (normalized != null) {
                        properties[name] = normalized
                        propertyCount++
                    }
                }
            }
            BoxTransferEdn(box.id, properties)
        }

        val watchManager = try {
            thisSessionInfo.get()?.fileWatchManager
        } catch (e: Exception) {
            null
        } ?: boxes.firstOrNull { it.isInsideInitialized() }?.inside?.fileWatchManager

        return SaveSnapshot(
            transfers = boxSnapshots,
            persistentProperties = persistent.toList(),
            fileWatchManager = watchManager,
            propertyCount = propertyCount,
            sidecarCount = sidecarCount,
            sidecarChars = sidecarChars
        )
    }

Phase 2: Perform all I/O operations in a background thread. This includes file writing and backups.

source — 240 lines, from line 540
    private fun saveInBackground(
        snapshot: SaveSnapshot,
        fn: String,
        boxes: Set<Box>,
        message: String?,
        onComplete: (() -> Unit)?,
        scheduledAtNs: Long,
        inline: Boolean = false,
    ) {
        // Runs on the project's standard miscPool, or on the caller when [inline] (see [save]).
        val work: () -> Unit = work@{
            val totalStartedAt = System.nanoTime()
            val launchDelayMs = (totalStartedAt - scheduledAtNs) / 1_000_000L
            var root = fn
            var ensureMs = 0L
            var lockWaitMs = 0L
            var guardMs = 0L
            var sidecarMs = 0L
            var ednMs = 0L
            var metadataMs = 0L
            var markdownBuildMs = 0L
            var markdownSaveMs = 0L
            var recorderMs = 0L
            var historyMs = 0L
            var metricsMs = 0L
            var versionIndexMs = 0L
            var screenshotMs = 0L
            var notifyMs = 0L
            var mainScheduleMs = 0L
            var altered = false
            var metadataChanged = false
            var markdownChanged = false
            var changedCount = 0
            var touchedCount = 0
            var versionIndex = -1
            try {
                val ensureStartedAt = System.nanoTime()
                root = ensureDirectory(fn)
                ensureMs = SaveReloadLog.elapsedMillis(ensureStartedAt)
                val lock = saveLocks.computeIfAbsent(File(root).canonicalPath) { Any() }
                val lockStartedAt = System.nanoTime()
                synchronized(lock) {
                    lockWaitMs = SaveReloadLog.elapsedMillis(lockStartedAt)
                    val guardStartedAt = System.nanoTime()
                    val blocked = suspiciousPartialAutosave(root, snapshot, message)
                    guardMs = SaveReloadLog.elapsedMillis(guardStartedAt)
                    blocked?.let {
                        val detail =
                            "doc=$fn boxes=${it.snapshotCount}/${it.existingCount} missing=${it.missingCount} missingPreview=${it.missingPreview}"
                        SaveReloadLog.log("save-blocked-partial", detail)
                        lprintln("error,save", "Blocked suspicious partial autosave: $detail")
                        _send(
                            Message("notify").payload(
                                Notify(
                                    "Blocked suspicious partial autosave: ${it.snapshotCount}/${it.existingCount} boxes. metadata.edn was left untouched.",
                                    "save"
                                )
                            ),
                            document = Regex.escape(fn)
                        )
                        return@work
                    }

                    val touched = mutableListOf<String>()
                    val alteredFiles = mutableListOf<String>()
                    // Write all files based on the snapshot
                    val sidecarStartedAt = System.nanoTime()
                    snapshot.transfers.forEach { transfer ->
                        transfer.properties.forEach { (name, value) ->
                            if (value is FileInline) {
                                val target = File(root, value.path).path
                                touched.add(target)
                                val cd = checkDifference(target, value.inline, maxBackups = 10)
                                altered = altered || cd
                                if (cd) alteredFiles.add(target)
                            }
                        }
                    }
                    sidecarMs = SaveReloadLog.elapsedMillis(sidecarStartedAt)

                    // Build and write the EDN metadata
                    val ednStartedAt = System.nanoTime()
                    val payloadBoxes = snapshot.transfers.map { transfer ->
                        linkedMapOf<String, Any?>(
                            "id" to transfer.id,
                            "properties" to referencingLargeSidecars(transfer.properties)
                        )
                    }
                    val payload = linkedMapOf<String, Any?>(
                        "version" to 2,
                        "boxes" to payloadBoxes,
                        "persistentProperties" to snapshot.persistentProperties
                    )
                    val ednText = Edn.write(payload, ednCustom)
                    ednMs = SaveReloadLog.elapsedMillis(ednStartedAt)
                    val metadataPath = File(root, METADATA_EDN).path
                    touched.add(metadataPath)
                    val metadataStartedAt = System.nanoTime()
                    metadataChanged = checkDifference(metadataPath, ednText)
                    metadataMs = SaveReloadLog.elapsedMillis(metadataStartedAt)
                    // Now, not with the sidecars at the end: the rest of a save can take long enough
                    // that the file's own watch event arrives first and is taken for an external edit.
                    snapshot.fileWatchManager?.markMetadataWrite(
                        File(metadataPath),
                        snapshot.transfers.map { it.id },
                        written = metadataChanged,
                        text = ednText
                    )
                    altered = altered || metadataChanged
                    if (metadataChanged) alteredFiles.add(metadataPath)

                    val markdownBuildStartedAt = System.nanoTime()
                    val markdownText = buildDocumentMarkdown(snapshot, File(root).name)
                    markdownBuildMs = SaveReloadLog.elapsedMillis(markdownBuildStartedAt)
                    val markdownPath = File(root, DOCUMENT_MARKDOWN).path
                    touched.add(markdownPath)
                    val markdownSaveStartedAt = System.nanoTime()
                    markdownChanged = checkDifference(markdownPath, markdownText, createBackup = false)
                    markdownSaveMs = SaveReloadLog.elapsedMillis(markdownSaveStartedAt)
                    altered = altered || markdownChanged
                    if (markdownChanged) alteredFiles.add(markdownPath)
                    touchedCount = touched.size
                    changedCount = alteredFiles.size

                    if (altered) {
                        val recorderStartedAt = System.nanoTime()
                        if (metadataChanged) {
                            FlightRecorder.checkpointOnMetadataChange(fn)
                        }
                        OperationTranscript.checkpoint(fn)
                        recorderMs = SaveReloadLog.elapsedMillis(recorderStartedAt)
                    }

                    SaveReloadLog.log(
                        "save",
                        "doc=$fn altered=$altered touched=${touched.size} changed=${alteredFiles.size} message=${message ?: ""} files=[${
                            touched.joinToString(
                                ","
                            )
                        }], changed=[${alteredFiles.joinToString(",")}]"
                    )

                    if (altered) {
                        // Notify history manager about the new version
                        val savedAt = System.currentTimeMillis()
                        // Root is excluded, as HistoryManager.extractBoxIds does when it reads a version from disk.
                        val boxIds = snapshot.transfers.map { it.id }.filterNot { it == "root" }.toSet()
                        val historyStartedAt = System.nanoTime()
                        HistoryManager.onVersionSaved(fn, boxIds, savedAt)
                        historyMs = SaveReloadLog.elapsedMillis(historyStartedAt)
                        val metricsStartedAt = System.nanoTime()
                        HistoryMetrics.recordCurrentSave(fn, savedAt)
                        metricsMs = SaveReloadLog.elapsedMillis(metricsStartedAt)
                        val versionStartedAt = System.nanoTime()
                        versionIndex = HistoryMetrics.currentVersionIndex(fn)
                        versionIndexMs = SaveReloadLog.elapsedMillis(versionStartedAt)
                        val screenshotStartedAt = System.nanoTime()
                        HistoryScreenshotCapture.captureFourWindowsOnSave(fn, versionIndex, savedAt)
                        screenshotMs = SaveReloadLog.elapsedMillis(screenshotStartedAt)
                        val notifyStartedAt = System.nanoTime()
                        _send(
                            Message("history-changed").payload(
                                HistoryChanged(
                                    filename = fn,
                                    timestamp = savedAt,
                                    versionIndex = versionIndex,
                                    boxCount = boxIds.size,
                                    byteSize = File(root, METADATA_EDN).length(),
                                    boxIds = boxIds.toList().sorted()
                                )
                            ),
                            document = Regex.escape(fn)
                        )
                        notifyMs = SaveReloadLog.elapsedMillis(notifyStartedAt)

                        // Mark saved sidecar files - needs to be done in main thread
                        val mainScheduleStartedAt = System.nanoTime()
                        inMain {
                            val mainStartedAt = System.nanoTime()
                            notify("saved file at <<timeago:${System.currentTimeMillis()}>>", "*saved")

                            val watchManager = snapshot.fileWatchManager
                            if (watchManager == null) {
                                SaveReloadLog.log("save-warning", "doc=$fn missing-watch-manager")
                            }
                            // metadata.edn was registered when it was written (markMetadataWrite).
                            watchManager?.let {
                                File(root).listFiles()?.forEach { file ->
                                    if (file.isFile && file.name.contains("_") &&
                                        Box.ioSeparateProperties.values.contains(file.extension)
                                    ) {
                                        it.markInternalSave(file)
                                    }
                                }
                            }
                            onComplete?.invoke()
                            Ai4AutoBoxNamer.scheduleAfterSave(fn, boxes, message)
                            val mainMs = SaveReloadLog.elapsedMillis(mainStartedAt)
                            SaveReloadLog.log(
                                "save-main-timing",
                                "ms=$mainMs doc=$fn watchManager=${watchManager != null} touched=$touchedCount"
                            )
                        }
                        mainScheduleMs = SaveReloadLog.elapsedMillis(mainScheduleStartedAt)
                    } else {
                        val mainScheduleStartedAt = System.nanoTime()
                        inMain {
                            val mainStartedAt = System.nanoTime()
                            onComplete?.invoke()
                            Ai4AutoBoxNamer.scheduleAfterSave(fn, boxes, message)
                            SaveReloadLog.logTiming(
                                "save-main-timing",
                                mainStartedAt,
                                "doc=$fn unchanged=true",
                                thresholdMs = 25L
                            )
                        }
                        mainScheduleMs = SaveReloadLog.elapsedMillis(mainScheduleStartedAt)
                    }
                }
            } catch (e: Exception) {
                SaveReloadLog.log("save-error", "doc=$fn error=${e.message}")
                lprintln("error,save", "Error during background save: ${e.message}")
                e.printStackTrace()
            } finally {
                val totalMs = SaveReloadLog.elapsedMillis(totalStartedAt)
                SaveReloadLog.log(
                    "save-timing",
                    "totalMs=$totalMs doc=$fn root=$root altered=$altered metadataChanged=$metadataChanged markdownChanged=$markdownChanged touched=$touchedCount changed=$changedCount versionIndex=$versionIndex launchDelayMs=$launchDelayMs ensureMs=$ensureMs lockWaitMs=$lockWaitMs guardMs=$guardMs sidecarMs=$sidecarMs ednMs=$ednMs metadataMs=$metadataMs markdownBuildMs=$markdownBuildMs markdownSaveMs=$markdownSaveMs recorderMs=$recorderMs historyMs=$historyMs metricsMs=$metricsMs versionIndexMs=$versionIndexMs screenshotScheduleMs=$screenshotMs notifyMs=$notifyMs mainScheduleMs=$mainScheduleMs"
                )
                if (totalMs >= 1000L) {
                    lprintln(
                        "warning,save",
                        "slow save doc=$fn totalMs=$totalMs sidecarMs=$sidecarMs metadataMs=$metadataMs markdownBuildMs=$markdownBuildMs markdownSaveMs=$markdownSaveMs historyMs=$historyMs metricsMs=$metricsMs screenshotScheduleMs=$screenshotMs"
                    )
                }
            }
        }
        if (inline) work() else GlobalScope.launch(Runloop.miscPool.asCoroutineDispatcher()) { work() }
    }

Data class to hold a snapshot of box data captured in the main thread.

source — 352 lines, from line 784
    private data class SaveSnapshot(
        val transfers: List<BoxTransferEdn>,
        val persistentProperties: List<String>,
        val fileWatchManager: FileWatchManager?,
        val propertyCount: Int,
        val sidecarCount: Int,
        val sidecarChars: Long
    )

    private fun suspiciousPartialAutosave(root: String, snapshot: SaveSnapshot, message: String?): PartialSaveBlock? {
        if (!message.isNullOrBlank()) return null

        val metadataFile = File(root, METADATA_EDN)
        if (!metadataFile.isFile) return null

        val existingIds = try {
            readEdnTransfers(root).map { it.id }.toSet()
        } catch (e: Exception) {
            SaveReloadLog.log("save-guard-warning", "doc=$root could-not-read-existing-metadata error=${e.message}")
            return null
        }
        val snapshotIds = snapshot.transfers.map { it.id }.toSet()
        if (existingIds.size < 4 || snapshotIds.isEmpty()) return null
        if (snapshotIds.size >= existingIds.size) return null

        val missing = existingIds - snapshotIds
        val missingThreshold = max(4, (existingIds.size + 3) / 4)
        if (missing.size < missingThreshold) return null

        return PartialSaveBlock(
            existingCount = existingIds.size,
            snapshotCount = snapshotIds.size,
            missingCount = missing.size,
            missingPreview = missing.take(8).joinToString(",")
        )
    }

    private fun buildDocumentMarkdown(snapshot: SaveSnapshot, documentName: String): String {
        val ordered = parentFirst(snapshot.transfers)
        val out = StringBuilder()

        out.append("# Field Document")
        if (documentName.isNotBlank()) {
            out.append(": ").append(markdownInline(documentName))
        }
        out.append("\n\n")
        out.append("This is a machine-readable narrative mirror of `metadata.edn`. ")
        out.append("The canonical storage format is still EDN; this file is written beside it for code and AI tools.\n\n")
        appendFence(
            out,
            "edn",
            "field-document",
            mapOf("version" to "1", "source" to METADATA_EDN),
            Edn.write(
                linkedMapOf<String, Any?>(
                    "format" to "field-document-md",
                    "version" to 1,
                    "source" to METADATA_EDN,
                    "boxes" to ordered.size,
                    "persistentProperties" to snapshot.persistentProperties
                ),
                ednCustom
            )
        )

        ordered.forEachIndexed { index, transfer ->
            appendBoxMarkdown(out, transfer, index)
        }

        return out.toString()
    }

    private fun appendBoxMarkdown(out: StringBuilder, transfer: BoxTransferEdn, index: Int) {
        val name = (transfer.properties["name"] as? String)?.ifBlank { null }
        val blockProperties = transfer.properties.mapNotNull { (propertyName, value) ->
            toMarkdownBlockProperty(propertyName, value, transfer.properties)
        }
        val blockNames = blockProperties.mapTo(mutableSetOf()) { it.name }
        val headerProperties = LinkedHashMap<String, Any?>()
        transfer.properties.forEach { (name, value) ->
            if (!blockNames.contains(name)) {
                headerProperties[name] = value
            }
        }
        val blockManifest = blockProperties.map { block ->
            linkedMapOf<String, Any?>(
                "name" to block.name,
                "language" to block.language,
                "path" to block.path,
                "lines" to lineCount(block.text),
                "characters" to block.text.length,
                "sha256" to sha256Hex(block.text)
            ).filterValues { it != null }
        }
        val parents = boxRefIds(transfer.properties["parents"])
        val children = boxRefIds(transfer.properties["children"])
        val narratives = blockProperties.mapNotNull { block ->
            extractLeadingNarrative(block.name, block.language, block.text)
        }

        out.append("## ").append(index + 1).append(". ")
        out.append(markdownInline(name ?: transfer.id)).append("\n\n")
        out.append("<!-- field-box id=\"").append(htmlAttribute(transfer.id)).append("\" index=\"")
            .append(index).append("\" -->\n\n")

        if (narratives.isNotEmpty()) {
            out.append("### Narrative\n\n")
            narratives.forEach { narrative ->
                out.append("From `").append(markdownInline(narrative.property)).append("`")
                if (narrative.tags.isNotEmpty()) {
                    out.append(" tags: ")
                    out.append(narrative.tags.joinToString(" ") { "`#$it`" })
                }
                out.append("\n\n")
                out.append(narrative.text.trim()).append("\n\n")
            }
        }

        val boxHeader = linkedMapOf<String, Any?>(
            "id" to transfer.id,
            "index" to index,
            "name" to name,
            "parents" to parents,
            "children" to children,
            "properties" to headerProperties,
            "blockProperties" to blockManifest
        ).filterValues { it != null }
        appendFence(
            out,
            "edn",
            "field-box",
            mapOf("id" to transfer.id, "role" to "properties"),
            Edn.write(boxHeader, ednCustom)
        )

        blockProperties.forEach { block ->
            appendFence(
                out,
                block.language,
                "field-property",
                buildMap {
                    put("box", transfer.id)
                    put("name", block.name)
                    block.path?.let { put("path", it) }
                },
                block.text
            )
        }
    }

    private fun parentFirst(transfers: List<BoxTransferEdn>): List<BoxTransferEdn> {
        val byId = transfers.associateBy { it.id }
        val visited = mutableSetOf<String>()
        val visiting = mutableSetOf<String>()
        val ordered = mutableListOf<BoxTransferEdn>()

        fun visit(transfer: BoxTransferEdn) {
            if (visited.contains(transfer.id)) return
            if (!visiting.add(transfer.id)) return
            boxRefIds(transfer.properties["parents"]).forEach { parentId ->
                byId[parentId]?.let { visit(it) }
            }
            visiting.remove(transfer.id)
            visited.add(transfer.id)
            ordered += transfer
        }

        byId["root"]?.let { visit(it) }
        transfers.forEach { visit(it) }
        return ordered
    }

    private fun toMarkdownBlockProperty(
        name: String,
        value: Any?,
        properties: Map<String, Any?>
    ): MarkdownBlockProperty? {
        val path: String?
        val text: String
        when (value) {
            is FileInline -> {
                path = value.path
                text = value.inline
            }

            is String -> {
                if (!shouldWriteStringAsBlock(name, value)) return null
                path = null
                text = value
            }

            else -> return null
        }
        return MarkdownBlockProperty(
            name = name,
            language = markdownLanguage(name, path, properties),
            text = text,
            path = path
        )
    }

    private fun shouldWriteStringAsBlock(name: String, value: String): Boolean {
        if (name == "name" || name == "filename" || name == "code_language") return false
        if (Box.ioSeparateProperties.containsKey(name)) return true
        return value.contains('\n')
    }

    private fun markdownLanguage(name: String, path: String?, properties: Map<String, Any?>): String {
        if (name == "code") {
            val declared = (properties["code_language"] as? String)?.trim()?.lowercase()
            if (!declared.isNullOrBlank()) return normalizeMarkdownLanguage(declared)
        }
        val extension = path?.substringAfterLast('.', missingDelimiterValue = "")?.lowercase()
            ?: Box.ioSeparateProperties[name]
        return normalizeMarkdownLanguage(extension ?: "text")
    }

    private fun normalizeMarkdownLanguage(language: String): String = when (language.lowercase()) {
        "kt", "kts", "kotlin" -> "kotlin"
        "js", "javascript" -> "javascript"
        "ts", "typescript" -> "typescript"
        "py", "python" -> "python"
        "clj", "clojure" -> "clojure"
        "yml", "yaml" -> "yaml"
        "md", "markdown" -> "markdown"
        "glsl" -> "glsl"
        "slang" -> "slang"
        "stacksl" -> "stacksl"
        "metal", "msl" -> "msl"
        "edn" -> "edn"
        else -> language.ifBlank { "text" }
    }

    private fun extractLeadingNarrative(property: String, language: String, text: String): ExtractedNarrative? {
        val lines = text.lines()
        val collected = mutableListOf<String>()
        var index = 0
        while (index < lines.size && lines[index].isBlank()) index++

        if (index < lines.size && lines[index].trimStart().startsWith("/*")) {
            var first = true
            while (index < lines.size) {
                val raw = lines[index]
                val trimmed = raw.trim()
                var body = raw.trimStart()
                if (first) {
                    body = body.removePrefix("/*")
                    first = false
                }
                val closes = body.contains("*/")
                body = body.substringBefore("*/")
                collected += cleanupCommentLine(body)
                index++
                if (closes) break
            }
        }

        while (index < lines.size) {
            val raw = lines[index]
            val trimmed = raw.trimStart()
            val body = when {
                trimmed.startsWith("//") -> trimmed.removePrefix("//")
                trimmed.startsWith("#") && language in setOf(
                    "python",
                    "yaml",
                    "text",
                    "markdown"
                ) -> trimmed.removePrefix("#")

                trimmed.startsWith(";") && language == "clojure" -> trimmed.removePrefix(";")
                collected.isNotEmpty() && raw.isBlank() -> ""
                else -> break
            }
            collected += cleanupCommentLine(body)
            index++
        }

        val narrative = collected.joinToString("\n").trim()
        if (narrative.isBlank()) return null
        val tags = Regex("""(?<![\w/])#([A-Za-z0-9][A-Za-z0-9_:/.-]*)""")
            .findAll(narrative)
            .map { it.groupValues[1] }
            .distinct()
            .toList()
        return ExtractedNarrative(property, narrative, tags)
    }

    private fun cleanupCommentLine(line: String): String {
        return line.trimStart().removePrefix("*").trimStart()
    }

    private fun appendFence(
        out: StringBuilder,
        language: String,
        marker: String,
        metadata: Map<String, String>,
        text: String
    ) {
        val longestFence = Regex("`+").findAll(text).map { it.value.length }.maxOrNull() ?: 0
        val fence = "`".repeat(maxOf(3, longestFence + 1))
        out.append(fence).append(language.ifBlank { "text" }).append(" [").append(marker)
        metadata.forEach { (key, value) ->
            out.append(' ').append(key).append("=\"").append(fenceInfoValue(value)).append('"')
        }
        out.append("]\n")
        out.append(text)
        if (!text.endsWith("\n")) out.append('\n')
        out.append(fence).append("\n\n")
    }

    private fun boxRefIds(value: Any?): List<String> = when (value) {
        is BoxRef -> listOf(value.id)
        is Collection<*> -> value.flatMap { boxRefIds(it) }
        else -> emptyList()
    }

    private fun lineCount(text: String): Int {
        if (text.isEmpty()) return 0
        return text.count { it == '\n' } + if (text.endsWith("\n")) 0 else 1
    }

    private fun sha256Hex(text: String): String {
        val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
        val hex = "0123456789abcdef"
        val out = StringBuilder(digest.size * 2)
        digest.forEach { byte ->
            val value = byte.toInt() and 0xff
            out.append(hex[value ushr 4])
            out.append(hex[value and 0x0f])
        }
        return out.toString()
    }

    private fun markdownInline(value: String): String {
        return value.replace(Regex("""[\r\n]+"""), " ").trim()
    }

    private fun htmlAttribute(value: String): String {
        return value
            .replace("&", "&amp;")
            .replace("\"", "&quot;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
    }

    private fun fenceInfoValue(value: String): String {
        return value
            .replace("\\", "\\\\")
            .replace("\"", "\\\"")
            .replace("\n", "\\n")
            .replace("\r", "\\r")
    }

fun saveAsTemplate(templateName: String, boxes: Set<Box>, templatesDir: String)

Save boxes as a standalone template file (metadata.edn without subdirectory)

    fun saveAsTemplate(templateName: String, boxes: Set<Box>, templatesDir: String) {
        val ednText = exportEdnPackage(boxes, templatesDir)
        val templateFile = File(templatesDir, "$templateName.edn")
        templateFile.writeText(ednText)
    }

fun load

    fun load(
        fn: String,
        filterId: (String) -> String = { it },
        upgradeOnLoad: Boolean = true,
        initIfCreated: Boolean = true
    ): Set<Box> {
        val file = File(fn)

        // Check if fn points to a standalone .edn file
        if (file.isFile && file.name.endsWith(".edn")) {
            val ednText = file.readText()
            val directory = file.absoluteFile.parentFile?.absolutePath ?: file.absolutePath
            return loadFromEdnText(ednText, directory, filterId, initIfCreated = initIfCreated)
        }

        // Otherwise, treat as directory
        val root = ensureDirectory(fn)
        val ednFile = File(root, METADATA_EDN)
        if (ednFile.exists()) {
            return loadEdn(root, filterId, initIfCreated = initIfCreated)
        }

        val jsonFile = File(root, METADATA_JSON)
        if (!jsonFile.exists()) {
            throw FileNotFoundException("missing $METADATA_EDN or $METADATA_JSON in $root")
        }

        val boxes = loadJson(root, filterId, initIfCreated = initIfCreated)
        if (upgradeOnLoad) {
            try {
                save(root, boxes, "upgrade to edn")
            } catch (ex: Exception) {
                lprintln("error,save", "error upgrading $root to EDN: ${ex.message}")
            }
        }
        return boxes
    }

fun resolveStoredValue(value: Any?, directory: String, preferInlineFiles: Boolean = false): Any?

    fun resolveStoredValue(value: Any?, directory: String, preferInlineFiles: Boolean = false): Any? {
        return resolveValue(value, directory, { it }, createBoxes = false, preferInlineFiles = preferInlineFiles)
    }

fun readInitialBoxFrames(fn: String, filterId: (String) -> String

    fun readInitialBoxFrames(fn: String, filterId: (String) -> String = { it }): List<InitialBoxFrame> {
        val file = File(fn)
        val transfers = when {
            file.isFile && file.name.endsWith(".edn") -> readEdnTransfersFromString(file.readText())
            else -> {
                val root = File(fn)
                val ednFile = File(root, METADATA_EDN)
                if (!ednFile.isFile) {
                    throw FileNotFoundException("missing $METADATA_EDN in ${root.path}")
                }
                readEdnTransfers(root.path)
            }
        }

        return transfers.mapNotNull { transfer ->
            if (transfer.id == "root") return@mapNotNull null
            if (transfer.properties["_tombstone"].isTrue()) return@mapNotNull null
            val frame = transfer.properties["frame"] as? Rect
                ?: coerceRect(transfer.properties["frame"])
                ?: return@mapNotNull null
            InitialBoxFrame(filterId(transfer.id), frame)
        }
    }

fun updateRootMetadata(directory: String, metadata: String): Boolean

body — 56 lines, from line 1213
    fun updateRootMetadata(directory: String, metadata: String): Boolean {
        val rootFile = File(directory, METADATA_EDN)
        if (!rootFile.isFile) {
            throw FileNotFoundException("missing $METADATA_EDN in $directory")
        }

        val rootValue = Edn.read(rootFile.readText(), ednCustom)
        val persistentProperties = when (rootValue) {
            is Map<*, *> -> {
                val savedProps = mapLookup(rootValue, "persistentProperties")
                when (savedProps) {
                    is Collection<*> -> savedProps.mapNotNull { it?.toString() }
                        .ifEmpty { Box.persistentPropertiesFor(directory).toList() }

                    else -> Box.persistentPropertiesFor(directory).toList()
                }
            }

            else -> Box.persistentPropertiesFor(directory).toList()
        }

        val transfers = readEdnTransfers(directory)
        if (transfers.isEmpty()) {
            throw IllegalArgumentException("document has no boxes")
        }

        val targetIndex = transfers.indexOfFirst { it.id == "root" }.let { if (it >= 0) it else 0 }
        val updatedTransfers = transfers.mapIndexed { index, transfer ->
            if (index != targetIndex) {
                transfer
            } else {
                val updatedProperties = LinkedHashMap<String, Any?>()
                updatedProperties.putAll(transfer.properties)
                if (metadata.isBlank()) {
                    updatedProperties.remove("metadata")
                } else {
                    updatedProperties["metadata"] = metadata
                }
                BoxTransferEdn(transfer.id, updatedProperties)
            }
        }

        val payloadBoxes = updatedTransfers.map { transfer ->
            linkedMapOf<String, Any?>(
                "id" to transfer.id,
                "properties" to transfer.properties
            )
        }
        val payload = linkedMapOf<String, Any?>(
            "version" to 2,
            "boxes" to payloadBoxes,
            "persistentProperties" to persistentProperties
        )

        return checkDifference(rootFile.path, Edn.write(payload, ednCustom))
    }

fun resolveStoredValueWithBoxes

body — 80 lines, from line 1270
    fun resolveStoredValueWithBoxes(
        value: Any?,
        directory: String,
        filterId: (String) -> String = { it },
        allowedRefIds: Set<String>? = null,
        root: SessionInformation? = null,
        preferInlineFiles: Boolean = false
    ): Any? {
        return resolveValue(
            value,
            directory,
            filterId,
            createBoxes = true,
            allowedRefIds = allowedRefIds,
            root = root,
            preferInlineFiles = preferInlineFiles
        )
    }

    private fun normalizedEdnKey(key: Any?): String? {
        val text = key?.toString() ?: return null
        return if (text.startsWith(":")) text.substring(1) else text
    }

    private fun mapLookup(map: Map<*, *>, key: String): Any? {
        map[key]?.let { return it }
        map[":$key"]?.let { return it }
        return map.entries.firstOrNull { normalizedEdnKey(it.key) == key }?.value
    }

    private fun numberLikeToDouble(value: Any?): Double? = when (value) {
        is Number -> value.toDouble()
        is String -> value.toDoubleOrNull()
        else -> null
    }

    private fun coerceRect(value: Any?): Rect? {
        val items = value as? List<*> ?: return null
        if (items.size != 4) return null
        val x = numberLikeToDouble(items[0]) ?: return null
        val y = numberLikeToDouble(items[1]) ?: return null
        val w = numberLikeToDouble(items[2]) ?: return null
        val h = numberLikeToDouble(items[3]) ?: return null
        return Rect(x, y, w, h)
    }

    private fun coerceBoxRefLike(value: Any?): BoxRef? = when (value) {
        is BoxRef -> value
        is String -> {
            val id = value.removePrefix(":").trim()
            if (id.isEmpty()) null else BoxRef(id)
        }

        else -> null
    }

    private fun normalizeTransferPropertyValue(name: String, value: Any?): Any? = when (name) {
        "frame", "viewport" -> coerceRect(value) ?: value
        "parents", "children", "nested" -> {
            val collection = value as? Collection<*>
            if (collection == null) {
                value
            } else {
                val refs = LinkedHashSet<BoxRef>()
                var valid = true
                for (item in collection) {
                    val ref = coerceBoxRefLike(item)
                    if (ref == null) {
                        valid = false
                        break
                    }
                    refs.add(ref)
                }
                if (valid) refs else value
            }
        }

        "nestedIn" -> coerceBoxRefLike(value) ?: value
        else -> value
    }

Records a document's :persistentProperties header against directory, so every property that document saved before keeps being saved, even if nothing has declared it in this process yet.

Recorded per document (Box.recordDocumentPersistentProperties), not merged into the global Box.ioPersistentProperties: one document's header must not change what another saves. A read with no directory (a template scan, a history version) records nothing.

@param rootValue The root EDN value from metadata.edn (should be a Map)

    private fun mergePersistentProperties(rootValue: Map<*, *>, directory: String?) {
        val savedProps = mapLookup(rootValue, "persistentProperties")
        if (savedProps != null) {
            val propsList = when (savedProps) {
                is List<*> -> savedProps.mapNotNull { it?.toString() }
                is Set<*> -> savedProps.mapNotNull { it?.toString() }
                else -> emptyList()
            }
            if (directory != null) Box.recordDocumentPersistentProperties(directory, propsList)
        }
    }

fun readEdnTransfers(directory: String): List<BoxTransferEdn>

    fun readEdnTransfers(directory: String): List<BoxTransferEdn> {
        val rootValue = Edn.read(File(directory, METADATA_EDN).readText(), ednCustom)
        // A document on a kit saves its kit's properties as sidecars (afield.document.workspace.Kits).
        afield.document.workspace.Kits.declareRecorded(File(directory))
        val boxesValue = when (rootValue) {
            is Map<*, *> -> {
                // Record the document's own persistent-property names against it
                mergePersistentProperties(rootValue, directory)
                mapLookup(rootValue, "boxes")
            }

            is List<*> -> rootValue
            is Set<*> -> rootValue.toList()
            else -> throw IllegalArgumentException("expected EDN map or list in ${File(directory, METADATA_EDN).path}")
        }
        val boxList = when (boxesValue) {
            null -> emptyList()
            is List<*> -> boxesValue
            is Set<*> -> boxesValue.toList()
            else -> throw IllegalArgumentException("expected boxes list in ${File(directory, METADATA_EDN).path}")
        }

        return boxList.mapNotNull { entry ->
            val map = entry as? Map<*, *> ?: return@mapNotNull null
            val id = mapLookup(map, "id")?.toString() ?: return@mapNotNull null
            val propsRaw = mapLookup(map, "properties") as? Map<*, *> ?: emptyMap<Any?, Any?>()
            val props = LinkedHashMap<String, Any?>()
            for ((k, v) in propsRaw) {
                val key = normalizedEdnKey(k) ?: continue
                props[key] = normalizeTransferPropertyValue(key, v)
            }
            BoxTransferEdn(id, props)
        }
    }

fun readInlineFileSnapshots(directory: String): List<InlineFileSnapshot>

Lists the inline fallbacks carried by a document without resolving them through loose files.

Field normally prefers <box>_<property>_.<ext> when that file exists, which means a stale inline fallback is invisible during ordinary use. Documentation artifacts need to compare the two copies explicitly, so this exposes the decoded FileInline values without making that persistence implementation type public.

    fun readInlineFileSnapshots(directory: String): List<InlineFileSnapshot> =
        readEdnTransfers(directory).flatMap { transfer ->
            transfer.properties.mapNotNull { (property, value) ->
                val file = value as? FileInline ?: return@mapNotNull null
                InlineFileSnapshot(transfer.id, property, file.path, file.inline)
            }
        }

fun refreshInlineFilesFromDisk(directory: String, allowInlineOnly: Boolean = false): List<String>

Refreshes every #afield/file-inline fallback from its loose file using Field's EDN codec.

This is the non-interactive equivalent of opening and saving the document: the loose file is the value Field loads, and Edn.write with ednCustom is the serializer Field uses on save. Only metadata.edn is rewritten; source sidecars, history and generated prose are untouched. Paths are confined to the document directory so a malformed artifact cannot read elsewhere. Returns the refreshed relative paths in document order.

A missing loose file is an error unless allowInlineOnly: then the inline copy is the only one, which is what Field loads, and it is kept as it is.

    fun refreshInlineFilesFromDisk(directory: String, allowInlineOnly: Boolean = false): List<String> {
        val refreshed = canonicalInlineMetadata(directory, allowInlineOnly)
        File(File(directory).canonicalFile, METADATA_EDN)
            .safeSave(refreshed.metadataEdn, createBackup = false, atomic = true)
        return refreshed.paths
    }

fun canonicalInlineMetadata(directory: String, allowInlineOnly: Boolean = false): InlineMetadata

What refreshInlineFilesFromDisk would write, without writing it.

Re-encoding is not the identity on a hand-authored artifact: the parser accepts \uXXXX in a string and the printer emits the character itself, so a metadata.edn that was never written by Field comes back changed with nothing semantically different about it. Comparing this against the committed bytes is how a guard tells "this document is the codec's own output" from "this document merely decodes to the same values" — the second one makes --write dirty a file nobody touched.

    fun canonicalInlineMetadata(directory: String, allowInlineOnly: Boolean = false): InlineMetadata {
        val root = File(directory).canonicalFile
        val metadata = File(root, METADATA_EDN)
        require(metadata.isFile) { "missing $METADATA_EDN in ${root.path}" }
        val rootPath = root.toPath().normalize()
        val refreshedPaths = mutableListOf<String>()

        fun refresh(value: Any?): Any? = when (value) {
            is FileInline -> {
                val target = File(root, value.path).canonicalFile
                require(target.toPath().normalize().startsWith(rootPath)) {
                    "inline file escapes document directory: ${value.path}"
                }
                if (!target.isFile && allowInlineOnly) value else {
                    require(target.isFile) { "missing inline sidecar ${value.path} in ${root.path}" }
                    refreshedPaths += value.path
                    value.copy(inline = target.readText())
                }
            }

            // What Field would save: a large sidecar is referenced, not inlined (see referencingLargeSidecars).
            is Map<*, *> -> LinkedHashMap<Any?, Any?>().also { out ->
                value.forEach { (key, item) ->
                    val refreshedItem = refresh(item)
                    // An inline-only copy stays inline: a reference to a file that is not there would load as empty.
                    out[refresh(key)] = if (refreshedItem is FileInline && key is String && referencesSidecar(key, refreshedItem.inline) &&
                        File(root, refreshedItem.path).isFile) FileRef(refreshedItem.path) else refreshedItem
                }
            }

            is List<*> -> value.map(::refresh)
            is Set<*> -> value.mapTo(LinkedHashSet(), ::refresh)
            else -> value
        }

        val decoded = Edn.read(metadata.readText(), ednCustom)
        return InlineMetadata(refreshedPaths, Edn.write(refresh(decoded), ednCustom))
    }

fun readEdnTransfersFromString(ednText: String, recordInto: String? = null): List<BoxTransferEdn>

Reads EDN text; recordInto is the document its header's persistent-property names belong to.

    fun readEdnTransfersFromString(ednText: String, recordInto: String? = null): List<BoxTransferEdn> {
        val rootValue = Edn.read(ednText, ednCustom)
        val boxesValue = when (rootValue) {
            is Map<*, *> -> {
                // Record the header's persistent-property names against the receiving document
                mergePersistentProperties(rootValue, recordInto)
                mapLookup(rootValue, "boxes")
            }

            is List<*> -> rootValue
            is Set<*> -> rootValue.toList()
            else -> throw IllegalArgumentException("expected EDN map or list in provided text")
        }
        val boxList = when (boxesValue) {
            null -> emptyList()
            is List<*> -> boxesValue
            is Set<*> -> boxesValue.toList()
            else -> throw IllegalArgumentException("expected boxes list in provided text")
        }

        return boxList.mapNotNull { entry ->
            val map = entry as? Map<*, *> ?: return@mapNotNull null
            val id = mapLookup(map, "id")?.toString() ?: return@mapNotNull null
            val propsRaw = mapLookup(map, "properties") as? Map<*, *> ?: emptyMap<Any?, Any?>()
            val props = LinkedHashMap<String, Any?>()
            for ((k, v) in propsRaw) {
                val key = normalizedEdnKey(k) ?: continue
                props[key] = normalizeTransferPropertyValue(key, v)
            }
            BoxTransferEdn(id, props)
        }
    }

internal fun storedBoxRefIds(value: Any?): Set<String>?

The box ids a stored topology value (children, parents) names, or null when it names none.

    internal fun storedBoxRefIds(value: Any?): Set<String>? = when (value) {
        null -> emptySet()
        is BoxRef -> setOf(value.id)
        is Collection<*> -> value.mapNotNullTo(LinkedHashSet()) { (it as? BoxRef)?.id }
        else -> null
    }

internal fun sidecarOrNewerInline(file: File, value: FileInline, directory: String): String

A sidecar's text at load, unless metadata.edn was written after it with a different inline copy: that is a hand edit of the inline text made while the document was closed, and it wins. The sidecar it replaces is copied into backups/ first and the choice is logged, so neither edit is lost. Two seconds of slack keep a checkout, which writes both files at once, on the sidecar.

body — 184 lines, from line 1541
    internal fun sidecarOrNewerInline(file: File, value: FileInline, directory: String): String {
        val text = file.readText()
        if (text == value.inline) return text
        val metadata = File(directory, METADATA_EDN)
        if (!metadata.isFile || metadata.lastModified() <= file.lastModified() + 2000) return text
        runCatching {
            val backup = File(directory, "backups/${file.name}.before-inline-${System.currentTimeMillis()}")
            backup.parentFile.mkdirs()
            file.copyTo(backup)
            lprintln(
                "warning,save",
                "${value.path}: metadata.edn's inline copy is newer than the file and differs from it, so the inline " +
                    "edit is loaded; the file's text is kept as backups/${backup.name}"
            )
        }.onFailure { e ->
            lprintln("warning,save", "${value.path}: could not back up the file before loading the newer inline copy (${e.message}); keeping the file")
            return text
        }
        return value.inline
    }

    private fun resolveValue(
        value: Any?,
        directory: String,
        filterId: (String) -> String,
        createBoxes: Boolean,
        allowedRefIds: Set<String>? = null,
        root: SessionInformation? = null,
        preferInlineFiles: Boolean = false,
        initIfCreated: Boolean = true
    ): Any? = when (value) {
        null -> null
        is FileRef -> File(directory, value.path).let { file ->
            if (file.isFile) file.readText() else {
                lprintln("warning,save", "document refers to sidecar ${value.path}, which is missing from $directory; loading it as empty")
                ""
            }
        }
        is FileInline -> {
            val file = File(directory, value.path)
            if (!preferInlineFiles && file.exists()) sidecarOrNewerInline(file, value, directory) else value.inline
        }

        is BoxRef -> {
            if (allowedRefIds != null && value.id !in allowedRefIds) {
                SkipRef
            } else if (!createBoxes) {
                value
            } else {
                val sourceId = value.id
                val id = filterId(sourceId)
                val resolvedRoot = root ?: thisSessionInfo.get()
                if (resolvedRoot == null) {
                    SaveReloadLog.log(
                        "resolve-boxref-missing-root",
                        "id=$id source=$sourceId directory=$directory"
                    )
                    SkipRef
                } else {
                    val v = box(id, createIfMissing = true, initIfCreated = initIfCreated, root = resolvedRoot)
                    if (id != sourceId) {
                        v.setPropertyRaw("source_id", sourceId)
                    }
                    v
                }
            }
        }

        is List<*> -> {
            val resolved = mutableListOf<Any?>()
            value.forEach { item ->
                val result =
                    resolveValue(item, directory, filterId, createBoxes, allowedRefIds, root, preferInlineFiles, initIfCreated)
                if (result !== SkipRef) {
                    resolved.add(result)
                }
            }
            resolved
        }

        is Set<*> -> {
            val resolved = LinkedHashSet<Any?>()
            value.forEach { item ->
                val result =
                    resolveValue(item, directory, filterId, createBoxes, allowedRefIds, root, preferInlineFiles, initIfCreated)
                if (result !== SkipRef) {
                    resolved.add(result)
                }
            }
            resolved
        }

        is Map<*, *> -> {
            val mapped = LinkedHashMap<Any?, Any?>()
            for ((k, v) in value) {
                val keyResolved =
                    resolveValue(k, directory, filterId, createBoxes, allowedRefIds, root, preferInlineFiles, initIfCreated)
                if (keyResolved === SkipRef) continue
                val valueResolved =
                    resolveValue(v, directory, filterId, createBoxes, allowedRefIds, root, preferInlineFiles, initIfCreated)
                if (valueResolved === SkipRef) continue
                mapped[keyResolved] = valueResolved
            }
            mapped
        }

        is EdnValue -> {
            lprintln("warning,decode", "can't decode $value")
            null
        }

        else -> value
    }

    private fun loadEdn(root: String, filterId: (String) -> String, initIfCreated: Boolean = true): Set<Box> {
        val transfers = readEdnTransfers(root)
        return instantiateTransfers(transfers, root, filterId, createBoxes = true, initIfCreated = initIfCreated)
    }

    private fun instantiateTransfers(
        transfers: List<BoxTransferEdn>,
        root: String,
        filterId: (String) -> String,
        createBoxes: Boolean,
        allowedRefIds: Set<String>? = null,
        preferInlineFiles: Boolean = false,
        initIfCreated: Boolean = true
    ): Set<Box> {
        val boxes = transfers.map { transfer ->
            val id = filterId(transfer.id)
            val b = box(id, createIfMissing = createBoxes, initIfCreated = initIfCreated)
            transfer.properties.forEach { (name, value) ->
                val v = resolveValue(
                    value,
                    root,
                    filterId,
                    createBoxes = createBoxes,
                    allowedRefIds = allowedRefIds,
                    preferInlineFiles = preferInlineFiles,
                    initIfCreated = initIfCreated
                )
                if (v != null) {
                    b.setPropertyRaw(name, v)
                }
            }
            if (id != transfer.id) {
                b.setPropertyRaw("source_id", transfer.id)
            }
            b
        }.toSet()

        reconcileLoadedTopology(boxes)
        return boxes
    }

    private fun reconcileLoadedTopology(boxes: Set<Box>) {
        // Loading through setPropertyRaw restores the forward refs but skips the normal mutation helpers
        // that would repair inverse topology on boxes outside the loaded payload.
        fun ensureConnection(parent: Box, child: Box) {
            if (!parent.children.contains(child)) {
                parent.setPropertyRaw("children", parent.children.toMutableSet().apply { add(child) })
            }
            if (!child.parents.contains(parent)) {
                child.setPropertyRaw("parents", child.parents.toMutableSet().apply { add(parent) })
            }
        }

        boxes.forEach { box ->
            box.parents
                .filter { !it.getPropertyRaw("_tombstone").isTrue() }
                .forEach { parent -> ensureConnection(parent, box) }

            box.children
                .filter { !it.getPropertyRaw("_tombstone").isTrue() }
                .forEach { child -> ensureConnection(box, child) }
        }

        // The nesting half of the same repair, and the load-bearing one: nearly every nested box in
        // a document predating `398cb0ca9` is persisted with `nestedIn` set and no edge at all. It
        // lived here, unnamed, from `03ad66360` until it was lifted to `nesting.kt` beside the two
        // live routes that maintain the same invariant, so that the paths which also materialise
        // boxes from persisted state can share it.
        reconcileNestingEdges(boxes)
    }

fun loadFromEdnText

body — 47 lines, from line 1726
    fun loadFromEdnText(
        ednText: String,
        directory: String,
        filterId: (String) -> String = { it },
        allowedRefIds: Set<String>? = null,
        preferInlineFiles: Boolean = false,
        initIfCreated: Boolean = true
    ): Set<Box> {
        val transfers = readEdnTransfersFromString(ednText, recordInto = directory)
        return instantiateTransfers(
            transfers,
            directory,
            filterId,
            createBoxes = true,
            allowedRefIds = allowedRefIds,
            preferInlineFiles = preferInlineFiles,
            initIfCreated = initIfCreated
        )
    }

    private fun loadJson(root: String, filterId: (String) -> String, initIfCreated: Boolean = true): Set<Box> {
        val transfers = format.decodeFromString<Set<Box_transfer>>(File(root, METADATA_JSON).readText())
        val boxes = transfers.map {
            val id = filterId(it.id)
            val b = box(id, createIfMissing = true, initIfCreated = initIfCreated)
            it.properties.forEach { (name, element) ->
                val v = fromTypedPair(element, root, filterId, initIfCreated = initIfCreated)
                if (v != null) {
                    b.setPropertyRaw(name, v)
                }
            }

            if (id != it.id) {
                b.setPropertyRaw("source_id", it.id)
            }
            b
        }.toSet()

        // The JSON path grew its own materialization loop rather than sharing `instantiateTransfers`,
        // and so never gained the repair that path has done since `03ad66360`. That mattered more here
        // than anywhere: [load] re-saves this result as EDN when `upgradeOnLoad`, so a legacy document
        // was converted *and* had its unrepaired topology written into the new `metadata.edn`, which
        // then read back repaired one open later. Reconciling before the upgrade makes the conversion
        // write what the loader would have derived anyway.
        reconcileLoadedTopology(boxes)
        return boxes
    }

fun fromTypedPair

    fun fromTypedPair(
        element: Pair<String, JsonElement>,
        directory: String,
        filterId: (String) -> String = { it },
        createBoxes: Boolean = true,
        initIfCreated: Boolean = true
    ): Any? = when (element.first) {
        "__file__" -> File(directory, format.decodeFromJsonElement<String>(element.second)).readText()
        "Rect" -> format.decodeFromJsonElement<Rect>(element.second) as Any
        "String" -> format.decodeFromJsonElement<String>(element.second) as Any
        "Int" -> format.decodeFromJsonElement<Int>(element.second) as Any
        "Number" -> format.decodeFromJsonElement<Number>(element.second) as Any
        "Boolean" -> format.decodeFromJsonElement<Boolean>(element.second) as Any
        "Box" -> {
            val sourceId = format.decodeFromJsonElement<String>(element.second)
            if (!createBoxes) {
                BoxRef(sourceId)
            } else {
                val id = filterId(sourceId)
                val v = box(id, createIfMissing = true, initIfCreated = initIfCreated)
                if (id != sourceId) {
                    v.setPropertyRaw("source_id", sourceId)
                }
                v
            }
        }

        "List" -> format.decodeFromJsonElement<List<Pair<String, JsonElement>>>(element.second).map {
            fromTypedPair(it, directory, filterId, createBoxes, initIfCreated)
        }.toMutableList() as Any

        "Set" -> format.decodeFromJsonElement<List<Pair<String, JsonElement>>>(element.second).map {
            fromTypedPair(it, directory, filterId, createBoxes, initIfCreated)
        }.toMutableSet() as Any

        else -> {
            lprintln("error,decode", "can't decode $element")
            null
        }
    }

companion object

val format

        val format = Json {
            prettyPrint = true
        }

const val METADATA_EDN = "metadata.edn"

const val METADATA_JSON = "metadata.json"

const val DOCUMENT_MARKDOWN = "document.md"

const val TRANSCRIPT_EDN = "transcript.edn"

const val OPERATIONS_YAML = "operations.yaml"

fun documentExistsOnDisk(fn: String): Boolean

Whether load would find a document at fn, or would be starting an empty one.

Opening a name that is not there is not an error in Field -- it is how a document gets created -- but an agent that asked for alife_walls and silently received a new empty document has no way to tell that from a load that worked, and the symptom it does see (boxes: []) has three other causes. This is the predicate that separates them, reported as documentExisted in AgentDocumentSnapshot.

It deliberately mirrors load's own three branches rather than testing for the directory: load calls ensureDirectory, so by the time anyone could ask afterwards the directory exists whether or not the document did. Metadata presence is the fact that survives that.

body — 53 lines, from line 1840
        fun documentExistsOnDisk(fn: String): Boolean {
            val file = File(fn)
            if (file.isFile && file.name.endsWith(".edn")) return true
            return File(file, METADATA_EDN).isFile || File(file, METADATA_JSON).isFile
        }

        private val saveLocks = ConcurrentHashMap<String, Any>()

        private val ednCustom = Edn.CustomTypes().apply {
            registerEdn(Rect::class, "afield/rect", { rect ->
                EdnValue.VectorVal(
                    listOf(
                        EdnValue.Number(rect.x.toString()),
                        EdnValue.Number(rect.y.toString()),
                        EdnValue.Number(rect.w.toString()),
                        EdnValue.Number(rect.h.toString())
                    )
                )
            }, { value ->
                val vec = value as? EdnValue.VectorVal
                    ?: throw IllegalArgumentException("expected vector for Rect, got $value")
                val items = vec.values
                if (items.size != 4) {
                    throw IllegalArgumentException("expected 4 values for Rect, got ${items.size}")
                }
                Rect(
                    ednValueToDouble(items[0]),
                    ednValueToDouble(items[1]),
                    ednValueToDouble(items[2]),
                    ednValueToDouble(items[3])
                )
            })

            registerStringPayload(BoxRef::class, "afield/box", { it.id }, { BoxRef(it) })
            registerEdn(FileInline::class, "afield/file-inline", { file ->
                EdnValue.MapVal(
                    linkedMapOf(
                        EdnValue.Str("path") to EdnValue.Str(file.path),
                        EdnValue.Str("inline") to EdnValue.Str(file.inline)
                    )
                )
            }, { value ->
                val map = value as? EdnValue.MapVal
                    ?: throw IllegalArgumentException("expected map for FileInline, got $value")
                var pathValue = map.values[EdnValue.Str("path")] as? EdnValue.Str
                var inlineValue = map.values[EdnValue.Str("inline")] as? EdnValue.Str
                if (pathValue == null && inlineValue == null) {
                    throw IllegalArgumentException("missing path/inline for FileInline: $value")
                }
                FileInline(pathValue?.value ?: "<<unset>>", inlineValue!!.value)
            })
            registerStringPayload(FileRef::class, "afield/file", { it.path }, { FileRef(it) })
        }

fun filenameFor(id: String, name: String, extension: String): String

        fun filenameFor(id: String, name: String, extension: String): String {
            return id + "_" + name + "_." + extension
        }

        private fun ednValueToDouble(value: EdnValue): Double = when (value) {
            is EdnValue.Number -> value.token.toDouble()
            is EdnValue.Str -> value.value.toDouble()
            else -> throw IllegalArgumentException("expected number, got $value")
        }
    }

fun ensureDirectory(fn: String): String

    fun ensureDirectory(fn: String): String {
        File(fn).mkdirs()
        // The eighth producer, and the only one that fires on *load* rather than on save:
        // `load()` calls this, so merely opening a document seeds `AGENTS.md` and `README.md`
        // beside it. (Older Fields also seeded a `README.md`; see [ensureAgentGuides].) Harmless in a scratch workspace, two untracked files in a repository — 28 of
        // this repository's 117 document directories have no tracked `AGENTS.md`, including the
        // shipped demo at `dev_docs/agents/demos/python_cells`.
        if (!ReadOnlyDocuments.blocksWritesTo(fn)) {
            ensureAgentGuides(fn)
        }
        return fn
    }

Seed a workspace with agent orientation without overwriting user files.

templates/WORKSPACE_AGENTS.md becomes AGENTS.md, copied only when the source exists and the destination does not. Copying failures are logged but never fail workspace creation.

Field used to seed a README.md as well. It was a description of the on-disk format, which the Document Guide panel then listed as though it were the document's docs; its content now lives in AGENTS.md. Copies already on disk are left alone and hidden from the panel (DocumentGuideFiles.SEEDED_WORKSPACE_README_TITLE).

    private fun ensureAgentGuides(workspaceDir: String) {
        try {
            val workspace = File(workspaceDir)
            if (!workspace.exists() || !workspace.isDirectory) return

            // AGENTS.md is auto-discovered by agent tooling and onboards an agent that opens this
            // folder to the on-disk format and the transcript/backdoor path (dev_docs/agents/).
            val guidesToCopy = mapOf(
                "templates/WORKSPACE_AGENTS.md" to "AGENTS.md"
            )

            // Get the repository root (go up from wherever we are)
            val repoRoot = findRepositoryRoot() ?: return

            guidesToCopy.forEach { (sourcePath, destName) ->
                val sourceFile = File(repoRoot, sourcePath)
                val destFile = File(workspace, destName)

                // Only copy if source exists and destination doesn't exist
                if (sourceFile.exists() && !destFile.exists()) {
                    try {
                        sourceFile.copyTo(destFile, overwrite = false)
                        lprintln("info,workspace", "Copied $destName to workspace: $workspaceDir")
                    } catch (e: Exception) {
                        // Don't fail workspace creation if guides can't be copied
                        lprintln("warning,workspace", "Could not copy $destName: ${e.message}")
                    }
                }
            }
        } catch (e: Exception) {
            // Silently continue if guide copying fails - it's not critical
            lprintln("debug,workspace", "Guide copying failed: ${e.message}")
        }
    }

Search at most ten parent directories from user.dir for build.gradle.kts or .git.

If neither marker is found, return user.dir so callers can still attempt source lookup and degrade through their normal missing-file checks.

    private fun findRepositoryRoot(): File? {
        // Try common locations relative to the classpath
        var current = File(System.getProperty("user.dir"))

        // Look up the tree for repository markers
        var attempts = 10
        while (attempts > 0 && current.parent != null) {
            // Check for repository markers
            if (File(current, "build.gradle.kts").exists() ||
                File(current, ".git").exists()
            ) {
                return current
            }
            current = current.parentFile ?: break
            attempts--
        }

        // Fallback: use current directory
        return File(System.getProperty("user.dir"))
    }
}

fun File.safeSave

Safely saves text to a file, creating backups of previous versions.

Changed from "backup" to "backups" directory to match the requirement that previous metadata.edn files should be stored in backups/ directory.

@param text The text content to save @param maxBackups Maximum backups retained for this file, or null for unlimited retention @return true if the file was modified, false if content was unchanged

body — 164 lines, from line 2003
fun File.safeSave(
    text: String,
    createBackup: Boolean = true,
    maxBackups: Int? = null,
    atomic: Boolean = false
): Boolean {
    require(maxBackups == null || maxBackups >= 0) { "maxBackups must be non-negative" }
    val file = this
    val startedAt = System.nanoTime()
    val existed = file.exists()
    var changed = false
    var currentChars = -1
    var readCurrentMs = 0L
    var latestBackupMs = 0L
    var backupCompareMs = 0L
    var nextBackupMs = 0L
    var renameMs = 0L
    var pruneMs = 0L
    var prunedBackups = 0
    var writeMs = 0L
    var backupCreated = false
    var alreadyBackedUp = false
    var latestBackupName = ""
    try {
        if (existed) {
            // Read current file content
            val readStartedAt = System.nanoTime()
            val currentContent = file.readText()
            readCurrentMs = SaveReloadLog.elapsedMillis(readStartedAt)
            currentChars = currentContent.length
            if (currentContent != text) {
                if (createBackup) {
                    val backupDir = File(file.parentFile, "backups")
                    val latestStartedAt = System.nanoTime()
                    val latestBackup = file.latestSafeSaveBackup(backupDir)
                    latestBackupMs = SaveReloadLog.elapsedMillis(latestStartedAt)
                    latestBackupName = latestBackup?.name ?: ""
                    val compareStartedAt = System.nanoTime()
                    alreadyBackedUp = latestBackup?.let { backup ->
                        runCatching { backup.readText() == currentContent }.getOrDefault(false)
                    } ?: false
                    backupCompareMs = SaveReloadLog.elapsedMillis(compareStartedAt)

                    if (!alreadyBackedUp) {
                        if (!backupDir.exists()) {
                            backupDir.mkdirs()
                        }

                        val nextStartedAt = System.nanoTime()
                        val backupFile = file.nextSafeSaveBackup(backupDir)
                        nextBackupMs = SaveReloadLog.elapsedMillis(nextStartedAt)
                        // Move the current file to the backup name. In atomic mode we COPY instead,
                        // so the original stays in place until the atomic rename replaces it — a
                        // reader never sees the file briefly absent (no FileNotFoundException window).
                        val renameStartedAt = System.nanoTime()
                        backupCreated = if (atomic) {
                            runCatching { Files.copy(file.toPath(), backupFile.toPath()); true }.getOrDefault(false)
                        } else {
                            file.renameTo(backupFile)
                        }
                        renameMs = SaveReloadLog.elapsedMillis(renameStartedAt)
                        if (backupCreated && maxBackups != null) {
                            val pruneStartedAt = System.nanoTime()
                            prunedBackups = file.pruneSafeSaveBackups(backupDir, maxBackups)
                            pruneMs = SaveReloadLog.elapsedMillis(pruneStartedAt)
                        }
                    }
                }
            } else {
                // If the content is the same, do nothing.
                return false
            }
        }
        // Write the new text to the file
        val writeStartedAt = System.nanoTime()
        if (atomic) {
            // Write to a temp file in the same directory, then atomically rename it into place, so a
            // concurrent reader (or another process sharing the workspace) never observes a
            // truncated/half-written file. Falls back to a plain replace if the filesystem lacks
            // atomic moves (temp is in the same dir, so this is the same volume).
            val dir = file.parentFile ?: file.absoluteFile.parentFile
            val tmp = File.createTempFile(".${file.name}.", ".tmp", dir)
            try {
                tmp.writeText(text)
                try {
                    Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
                } catch (_: AtomicMoveNotSupportedException) {
                    Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
                }
            } finally {
                if (tmp.exists()) tmp.delete()
            }
        } else {
            file.writeText(text)
        }
        writeMs = SaveReloadLog.elapsedMillis(writeStartedAt)
        changed = true
        return true
    } finally {
        val totalMs = SaveReloadLog.elapsedMillis(startedAt)
        if (changed || totalMs >= 250L) {
            SaveReloadLog.log(
                "safe-save-timing",
                "ms=$totalMs path=${file.path} changed=$changed existed=$existed createBackup=$createBackup maxBackups=${maxBackups ?: "unlimited"} inputChars=${text.length} currentChars=$currentChars readCurrentMs=$readCurrentMs latestBackupMs=$latestBackupMs backupCompareMs=$backupCompareMs nextBackupMs=$nextBackupMs renameMs=$renameMs pruneMs=$pruneMs prunedBackups=$prunedBackups writeMs=$writeMs backupCreated=$backupCreated alreadyBackedUp=$alreadyBackedUp latestBackup=$latestBackupName"
            )
        }
        if (totalMs >= 1000L) {
            lprintln(
                "warning,save",
                "slow safeSave path=${file.path} ms=$totalMs readCurrentMs=$readCurrentMs latestBackupMs=$latestBackupMs backupCompareMs=$backupCompareMs nextBackupMs=$nextBackupMs renameMs=$renameMs writeMs=$writeMs"
            )
        }
    }
}

private fun File.safeSaveBackupPrefix(): String = "${nameWithoutExtension}_backup_"

private fun File.safeSaveBackupName(index: Int): String {
    val indexStr = String.format("%05d", index)
    return if (extension.isNotEmpty()) {
        "${safeSaveBackupPrefix()}$indexStr.$extension"
    } else {
        "${safeSaveBackupPrefix()}$indexStr"
    }
}

private fun File.safeSaveBackupIndex(backup: File): Int? {
    val prefix = safeSaveBackupPrefix()
    val suffix = if (extension.isNotEmpty()) ".${extension}" else ""
    val name = backup.name
    if (!name.startsWith(prefix)) return null
    if (suffix.isNotEmpty() && !name.endsWith(suffix)) return null
    val index = name.removePrefix(prefix).let {
        if (suffix.isNotEmpty()) it.removeSuffix(suffix) else it
    }
    return index.toIntOrNull()
}

private fun File.latestSafeSaveBackup(backupDir: File): File? {
    if (!backupDir.isDirectory) return null
    return backupDir.listFiles()
        ?.mapNotNull { backup -> safeSaveBackupIndex(backup)?.let { it to backup } }
        ?.maxByOrNull { it.first }
        ?.second
}

private fun File.nextSafeSaveBackup(backupDir: File): File {
    val nextIndex = backupDir.listFiles()
        ?.mapNotNull(::safeSaveBackupIndex)
        ?.maxOrNull()
        ?.plus(1)
        ?: 0
    return File(backupDir, safeSaveBackupName(nextIndex))
}

private fun File.pruneSafeSaveBackups(backupDir: File, maxBackups: Int): Int {
    if (!backupDir.isDirectory) return 0
    val obsolete = backupDir.listFiles()
        ?.mapNotNull { backup -> safeSaveBackupIndex(backup)?.let { it to backup } }
        ?.sortedByDescending { it.first }
        ?.drop(maxBackups)
        .orEmpty()
    return obsolete.count { (_, backup) -> backup.delete() }
}