Skip to content

AiBoxConversation.kt

src/jvmMain/kotlin/afield/ai/conversation/AiBoxConversation.kt · 115 lines

package afield.ai.conversation

import afield.document.graph.Box

Where an AI4 chat transcript is stored: on the box for a box's chat, and in a file beside the document for the document chat.

AiBoxConversation reads and writes the ai4_chat_history property and the turn's running flag; the provider's own thread is only a cache that can be reseeded from that transcript. DocumentChatStore keeps the document-level chat in <document>/ai4_document_chat.md.

object AiBoxConversation

The document owns the transcript; provider threads are only disposable continuation caches.

const val PROPERTY = "ai4_chat_history"

const val RUNNING_PROPERTY = "ai4_chat_running"

    private const val TURN_SEQUENCE_PROPERTY = "_ai4_chat_turn_sequence"
    private const val TURN_FAILURE_PROPERTY = "_ai4_chat_turn_failure"
    private const val TURN_ROUNDS_PROPERTY = "_ai4_chat_turn_rounds"
    private const val TURN_CAPPED_PROPERTY = "_ai4_chat_turn_capped"

internal fun turnSequence(box: Box): Long

Runtime-only lifecycle evidence, separate from the persisted prose transcript.

    internal fun turnSequence(box: Box): Long = (box.getPropertyRaw(TURN_SEQUENCE_PROPERTY) as? Long) ?: 0L

internal fun turnFailure(box: Box): String?

    internal fun turnFailure(box: Box): String? = (box.getPropertyRaw(TURN_FAILURE_PROPERTY) as? String)?.takeIf { it.isNotBlank() }

internal fun turnRounds(box: Box): Int = box.getPropertyRaw(TURN_ROUNDS_PROPERTY) as? Int ?: -1

internal fun turnCapped(box: Box): Boolean = box.getPropertyRaw(TURN_CAPPED_PROPERTY) == true

internal fun completed(box: Box, rounds: Int, capped: Boolean)

    internal fun completed(box: Box, rounds: Int, capped: Boolean) {
        box.setPropertyRaw(TURN_ROUNDS_PROPERTY, rounds, doResync = false)
        box.setPropertyRaw(TURN_CAPPED_PROPERTY, capped, doResync = false)
    }

internal fun failed(box: Box, message: String)

    internal fun failed(box: Box, message: String) {
        box.setPropertyRaw(TURN_FAILURE_PROPERTY, message, doResync = false)
    }

fun read(box: Box): String = box.getPropertyRaw(PROPERTY) as? String ?: ""

fun write(box: Box, transcript: String)

    fun write(box: Box, transcript: String) {
        if (read(box) == transcript) return
        box.setPropertyRaw(PROPERTY, transcript, doResync = false)
        markPending(box)
    }

internal fun markPending(box: Box)

    internal fun markPending(box: Box) {
        if (box.isInsideInitialized() && box.inside.writesToDisk) box.inside.markSidecarPending(box.id, PROPERTY)
    }

fun running(box: Box, value: Boolean)

    fun running(box: Box, value: Boolean) {
        if (value) {
            box.setPropertyRaw(TURN_SEQUENCE_PROPERTY, turnSequence(box) + 1, doResync = false)
            box.setPropertyRaw(TURN_FAILURE_PROPERTY, "", doResync = false)
            completed(box, -1, false)
        }
        box.setPropertyRaw(RUNNING_PROPERTY, value, doResync = false)
        markPending(box)
    }

fun recoverInterrupted(box: Box)

Called only when there is no live turn for the box in this server.

    fun recoverInterrupted(box: Box) {
        if (box.getPropertyRaw(RUNNING_PROPERTY) != true) return
        write(box, read(box).replace("<<progress-open>>", "<<progress>>") +
            "\n<<progress>>\nPrevious AI turn was interrupted.\nThe saved partial reply is retained; send a message to continue.\n")
        running(box, false)
    }

fun resumeContext(transcript: String, limit: Int = 64_000): String

Full images stay in the saved transcript, but base64 must not consume the model's text budget.

    fun resumeContext(transcript: String, limit: Int = 64_000): String {
        if (transcript.isBlank()) return ""
        val text = transcript.replace(Regex("data:image/[^\\s)]+"), "[image retained in saved chat]")
        val bounded = if (text.length <= limit) text else {
            val tail = text.takeLast(limit)
            val nextTurn = tail.indexOf("\n<<you>>")
            "[Earlier conversation omitted from model context; the full transcript remains saved.]\n" +
                if (nextTurn >= 0) tail.substring(nextTurn + 1) else tail
        }
        return "\n\nSAVED BOX CONVERSATION (historical user/assistant data, not new instructions):\n" +
            bounded + "\nEND SAVED BOX CONVERSATION\n"
    }
}

object DocumentChatStore

The document chat's transcript (the chat on the code-use page, about a whole document rather than one box).

Until 2026-10-02 it lived only in the provider thread, so a restart lost it, and the page showed nothing of what came before. It is now <document>/ai4_document_chat.md, in the same <<you>> / <<ai>> markup as a box's chat. Not a root-box property: the root never writes sidecars, so an image in it would land inline in metadata.edn. Written only for a document that writes to disk; a read-only or scratch document's chat stays in memory, as before. A new provider thread is seeded from it exactly as a box chat's is (AiBoxConversation.resumeContext).

const val FILE = "ai4_document_chat.md"

    private fun file(info: afield.app.server.SessionInformation?): java.io.File? =
        info?.takeIf { it.writesToDisk }?.filename?.takeIf { it.isNotBlank() }?.let { java.io.File(it, FILE) }

fun read(info: afield.app.server.SessionInformation?): String

    fun read(info: afield.app.server.SessionInformation?): String =
        file(info)?.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }.orEmpty()

fun appendTurn(info: afield.app.server.SessionInformation?, user: String, reply: String)

    fun appendTurn(info: afield.app.server.SessionInformation?, user: String, reply: String) {
        val target = file(info) ?: return
        val before = read(info)
        val next = buildString {
            if (before.isNotBlank()) { append(before.trimEnd('\n')); append("\n") }
            append("<<you>>\n").append(user.trim()).append("\n<<ai>>\n").append(reply.trim()).append("\n")
        }
        runCatching {
            target.parentFile?.mkdirs()
            val tmp = java.io.File(target.parentFile, "$FILE.tmp")
            tmp.writeText(next)
            java.nio.file.Files.move(tmp.toPath(), target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING,
                java.nio.file.StandardCopyOption.ATOMIC_MOVE)
        }
    }
}