Skip to content

AiStatePruning.kt

src/jvmMain/kotlin/afield/ai/conversation/AiStatePruning.kt · 108 lines

package afield.ai.conversation

import afield.ai.tools.AiStateBlobs
import afield.document.graph.Box
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.io.File

Housekeeping for the box states AI4 conversations save under <document>/ai4_blobs/state/.

AiStatePruning.sweepOrphans removes states no session tree refers to, and Box.pruneAiStates trims old states along each branch on request, reporting by default rather than deleting.

object AiStatePruning

Keeping <document>/ai4_blobs/state/ (the box states AI4 conversations save after each request that changes them) from growing without bound, without deleting a state a turn can still be restored to.

Two levels. sweepOrphans deletes only states that no session tree in the document refers to (left by a conversation that was cleared or a box that was deleted); AiStateBlobs.put runs it once per document per session. Box.pruneAiStates is the one that gives history up, and only when asked: it keeps the latest few states along every branch and the state at every fork, and by default only reports.

object AiStatePruning {

data class Report(val kept: Int, val removed: List<File>, val dryRun: Boolean)

What a sweep or prune found: the files it removed (or would remove), and the bytes they hold.

val bytes: Long get() = removed.sumOf { it.length() }

override fun toString()

        override fun toString() =
            "${if (dryRun) "would remove" else "removed"} ${removed.size} saved state(s), " +
                "${"%.1f".format(bytes / 1e6)} MB; kept $kept"
    }

const val GRACE_MILLIS = 10 * 60 * 1000L

A state file younger than this may belong to a request still writing its turn, so it is never swept.

    private fun JsonObject.str(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull

The state hashes a node records, before and after its request, for every extension key.

    private fun statesOf(node: JsonObject): List<String> {
        val branch = node["branch"] as? JsonObject ?: return emptyList()
        return listOf("before", "after").flatMap { which ->
            (branch[which] as? JsonObject)?.values?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }.orEmpty()
        }
    }

fun referenced(sessions: List<AiSessionTree.Session>): Set<String>

Every state hash any of sessions refers to.

    fun referenced(sessions: List<AiSessionTree.Session>): Set<String> =
        sessions.flatMap { s -> s.nodes().values.flatMap(::statesOf) }.toSet()

fun retained(sessions: List<AiSessionTree.Session>, keepPerBranch: Int): Set<String>

The hashes Box.pruneAiStates keeps: along the path to every tip, the last keepPerBranch distinct after states of each extension key, and every state recorded at a node with more than one child.

    fun retained(sessions: List<AiSessionTree.Session>, keepPerBranch: Int): Set<String> {
        require(keepPerBranch >= 1) { "keepPerBranch must be at least 1" }
        val out = HashSet<String>()
        for (s in sessions) {
            val nodes = s.nodes()
            val children = nodes.values.groupingBy { it.str("parent") }.eachCount()
            nodes.values.filter { (children[it.str("id")] ?: 0) > 1 }.forEach { out += statesOf(it) }
            val tips = nodes.keys.filter { (children[it] ?: 0) == 0 }
            for (tip in tips) {
                val perKey = LinkedHashMap<String, MutableList<String>>()
                s.path(tip).forEach { node ->
                    ((node["branch"] as? JsonObject)?.get("after") as? JsonObject)?.forEach { (key, value) ->
                        val sha = (value as? JsonPrimitive)?.contentOrNull ?: return@forEach
                        val seq = perKey.getOrPut(key) { mutableListOf() }
                        seq.remove(sha); seq += sha
                    }
                }
                perKey.values.forEach { out += it.takeLast(keepPerBranch) }
            }
        }
        return out
    }

fun sweep(folder: File, keep: Set<String>, dryRun: Boolean, graceMillis: Long = GRACE_MILLIS, now: Long = System.currentTimeMillis()): Report

The state files in folder whose hash is not in keep, older than graceMillis; deleted unless dryRun.

    fun sweep(folder: File, keep: Set<String>, dryRun: Boolean, graceMillis: Long = GRACE_MILLIS, now: Long = System.currentTimeMillis()): Report {
        val files = folder.listFiles { f -> f.isFile && f.name.endsWith(".state.gz") }.orEmpty()
        val (held, loose) = files.partition { it.name.removeSuffix(".state.gz") in keep || now - it.lastModified() < graceMillis }
        if (!dryRun) loose.forEach { it.delete() }
        return Report(held.size, loose, dryRun)
    }

internal fun sessionsOf(box: Box): List<AiSessionTree.Session>

Every box's session tree in box's document.

    internal fun sessionsOf(box: Box): List<AiSessionTree.Session> =
        if (!box.isInsideInitialized()) listOf(AiSessionTree.load(box))
        else box.inside.boxes.values.filter { (it.getPropertyRaw(AiSessionTree.PROPERTY) as? String)?.isNotBlank() == true }.map { AiSessionTree.load(it) }

fun sweepOrphans(box: Box, dryRun: Boolean = false): Report?

Delete the states in box's document that no session tree refers to.

    fun sweepOrphans(box: Box, dryRun: Boolean = false): Report? {
        val folder = AiStateBlobs.folderFor(box) ?: return null
        return sweep(folder, referenced(sessionsOf(box)), dryRun)
    }
}

fun Box.pruneAiStates(keepPerBranch: Int = 3, dryRun: Boolean = true): AiStatePruning.Report?

Free disk taken by this document's saved AI4 box states, keeping the latest keepPerBranch along every branch of every conversation and the state at every fork. A turn whose state is removed can still be rewound to, but its box is not restored. Reports what it would remove unless dryRun is false.

fun Box.pruneAiStates(keepPerBranch: Int = 3, dryRun: Boolean = true): AiStatePruning.Report? {
    val folder = AiStateBlobs.folderFor(this) ?: return null
    return AiStatePruning.sweep(folder, AiStatePruning.retained(AiStatePruning.sessionsOf(this), keepPerBranch), dryRun)
}