Skip to content

StateTreeBrowser.kt

src/jvmMain/kotlin/afield/ai/tools/StateTreeBrowser.kt · 162 lines

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

import afield.ai.conversation.AiSessionTree
import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import afield.study.source.SourceStudy
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull

The States browser of AI4: how a box exposes a saved history with stateTree, and the read-only view of it shared by the browser and by chat references.

A provider supplies a snapshot, a reader for one saved state and, optionally, a fork callback. StateTreeBrowser pages and bounds the text it shows, and never restores a state.

fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String, fork: ((String, String) -> String)?) {

Expose an existing history to AI4's States browser. Callbacks only read saved data; they must not restore state or run admission checks. read returns metadata; inspect reads one exact saved ID. Optional fork creates a named saved child and returns its ID without activation or restoration. Leave it null for histories that cannot safely retain branches. Re-running replaces this named provider and the run's lifecycle owns its registration.

fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String, fork: ((String, String) -> String)?) {
    require(name.matches(Regex("[A-Za-z0-9_-]{1,80}"))) { "Use a simple state tree name" }
    val key = "_stateTree:$name"
    val binding = StateTreeBinding(this, name, label, read, inspect, fork)
    properties[key] = binding
    LifecycleLedger.claim("state-tree", "${id}:$name", slot = LedgerSlot(properties, key), owner = this,
        scope = RunScope.currentFor(this), live = { properties[key] === binding },
        policy = ReleasePolicy.ACCUMULATING, release = { if (properties[key] === binding) properties.remove(key) })
}

fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String) =

Read-only provider overload; keeps the inspector usable as a trailing lambda.

fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String) =
    stateTree(name, label, read, inspect, null)

fun Box.stateTree(name: String, label: String, study: SourceStudy<*>)

Browse a SourceStudy without invoking its compiler or candidate checks.

fun Box.stateTree(name: String, label: String, study: SourceStudy<*>) = stateTree(name, label, {
    StateTreeSnapshot(study.allRevisions().map { StateTreeNode(it.id, listOfNotNull(it.parent), it.description,
        "source", it.created.orEmpty()) }, study.currentId(), "Saved source revisions; live edits may differ.")
}, study::savedSource, study::forkSaved)

internal class StateTreeBinding(val owner: Box, val name: String, val label: String,

internal class StateTreeBinding(val owner: Box, val name: String, val label: String,
    val read: () -> StateTreeSnapshot, val inspect: (String) -> String, val fork: ((String, String) -> String)? = null)

object StateTreeBrowser

Read-only projection shared by the browser and its chat references. No navigation or restoration.

object StateTreeBrowser {
    private const val PAGE_SIZE = 400
    private fun JsonObject.str(key: String) = (this[key] as? JsonPrimitive)?.contentOrNull

internal fun excerpt(text: String, limit: Int): String

Bound text before it reaches either the browser or a model; never inline image blobs.

    internal fun excerpt(text: String, limit: Int): String {
        val cleaned = text.replace(Regex("data:[^\\s)]*"), "[image omitted]")
            .replace(Regex("ai4img:[^\\s)]*"), "[saved image]")
        return if (cleaned.length <= limit) cleaned else cleaned.take(limit) + "\n[Excerpt; ${cleaned.length} characters total]"
    }

    private fun conversation(box: Box): StateTreeBinding = StateTreeBinding(box, "conversation", "Conversation", {
        val s = AiSessionTree.load(box)
        val nodes = s.nodes().values.map { n -> StateTreeNode(n.str("id").orEmpty(), listOfNotNull(n.str("parent")),
            excerpt(n.str("branchName") ?: n.str("user")?.takeIf { it.isNotBlank() } ?: AiSessionTree.userText(n.str("markdown").orEmpty())
                .ifBlank { "${n.str("t")} transcript" }, 180), n.str("t").orEmpty(), n.str("at").orEmpty()) }
        StateTreeSnapshot(nodes, s.head(), "Conversation history and registered snapshots. This is not a complete document backup.")
    }, { id ->
        val s = AiSessionTree.load(box)
        val node = requireNotNull(s.nodes()[id]) { "This conversation state is no longer available. Refresh the tree." }
        val snapshots = linkedMapOf<String, String>()
        s.path(id).forEach { n -> ((n["branch"] as? JsonObject)?.get("after") as? JsonObject)?.forEach { (k, v) ->
            snapshots[k] = (v as? JsonPrimitive)?.contentOrNull.orEmpty()
        } }
        buildString {
            appendLine("Conversation state: $id")
            appendLine("Parents: ${node.str("parent") ?: "none"}")
            appendLine("Registered state snapshots: ${if (snapshots.isEmpty()) "none recorded" else snapshots.entries.joinToString { "${it.key}=${it.value}" }}")
            appendLine("This records a conversation position; it does not assert a complete document snapshot.")
            appendLine("\nSaved conversation transcript:\n${s.renderRaw(id)}")
        }
    }, { id, name -> AiSessionTree.fork(box, id, name) })

    private fun hasBranchState(box: Box): Boolean =
        AgentExtensionRegistry.hooksFor(documentKeyFor(box)).any { it is AgentExtensionRegistry.BranchStateHook } ||
            AiSessionTree.load(box).nodes().values.any { node ->
                (node["branch"] as? JsonObject)?.values?.any { (it as? JsonObject)?.isNotEmpty() == true } == true
            }

internal fun bindings(boxes: Collection<Box>): Map<String, StateTreeBinding>

    internal fun bindings(boxes: Collection<Box>): Map<String, StateTreeBinding> = boxes.distinct().flatMap { box ->
        val providers = box.properties.values.filterIsInstance<StateTreeBinding>().filter { it.owner === box }
        if ((box.getPropertyRaw(AiSessionTree.PROPERTY) as? String).isNullOrBlank() &&
            AgentExtensionRegistry.hooksFor(documentKeyFor(box)).none { it is AgentExtensionRegistry.BranchStateHook && it.box === box }) providers
        else listOf(conversation(box)) + providers
    }.associateBy { "${it.owner.id}/${it.name}" }

internal fun label(b: StateTreeBinding)

    internal fun label(b: StateTreeBinding) = "${b.owner.getPropertyRaw("name") ?: b.owner.id} · ${b.label}".take(200)

fun read(boxes: Collection<Box>, request: StateTreeRequest): StateTreeReply

    fun read(boxes: Collection<Box>, request: StateTreeRequest): StateTreeReply = runCatching {
        require(boxes.any { it.id == request.uid }) { "Chat box no longer exists." }
        val indexed = bindings(boxes)
        val sources = indexed.map { (id, b) -> StateTreeSource(id, label(b), b.name != "conversation" || hasBranchState(b.owner)) }
        if (request.source.isEmpty()) return@runCatching StateTreeReply(sources = sources)
        val binding = requireNotNull(indexed[request.source]) { "History provider is unavailable. Run its box, then refresh." }
        val snapshot = binding.read()
        require(snapshot.nodes.map { it.id }.distinct().size == snapshot.nodes.size) { "History provider returned duplicate state IDs." }
        if (request.node.isNotEmpty()) {
            val n = requireNotNull(snapshot.nodes.find { it.id == request.node }) { "Saved state is no longer available. Refresh the tree." }
            val detail = excerpt(binding.inspect(n.id), 10000)
            val label = sources.first { it.id == request.source }.label
            // Canonical identity only. AI4 retrieves contents through StateReadRequest on demand.
            val reference = "State reference: ${excerpt(n.title, 180)}\n" +
                "StateReadRequest(history=${aiToolMapper.writeValueAsString(request.source)}, state=${aiToolMapper.writeValueAsString(n.id)})\n" +
                "History: $label\nSelected for discussion only; no state was restored."
            StateTreeReply(source = request.source, detail = detail, reference = reference)
        } else {
            val offset = request.offset.coerceIn(0, snapshot.nodes.size)
            val end = (snapshot.nodes.size - offset).coerceAtLeast(0)
            val page = snapshot.nodes.subList((end - PAGE_SIZE).coerceAtLeast(0), end).map {
                it.copy(title = excerpt(it.title, 180), created = it.created.take(80))
            }
            StateTreeReply(source = request.source, tree = snapshot.copy(nodes = page, note = excerpt(snapshot.note, 1000)),
                total = snapshot.nodes.size, offset = offset)
        }
    }.getOrElse { StateTreeReply(error = it.message ?: "Could not read this state history.") }
}