Skip to content

ChatlogTools.kt

src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatlogTools.kt · 311 lines

package afield.ai.conversation.chatlog

import afield.document.graph.Box
import afield.app.server.ReadOnlyDocuments
import afield.ai.tools.aiTool
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
import java.util.Base64

Chat-log tools: AI4 agents that read chats and write lenses

A box hands its document's AI4 agents a chat archive by running, once:

registerChatlogTools(File("/path/to/workspace"))
Agents then see chatlog.list, chatlog.outline, chatlog.read, chatlog.search, chatlog.images and lens.write, lens.read, lens.list. The archive is only read (see ChatArchive); the chats can live in another workspace, including one a person is working in.

A lens is a shorter chat written over a longer one: the same <<you>>/<<ai>> markup, so the chat panel renders it, with a <<source:a..b>> line at the top of each block saying which stretch of the chat it stands for. Lenses are saved in lensDir, by default this document's lenses/ folder, as <chat>/<name>.md beside a <name>.json record of the brief that made it.

Chats are named <document> when the document has one chat, otherwise <document>/<box name or id>. A lens is itself a chat, named <chat>#<lens>: the tools outline, read and cite it like any other, so a lens can be written over a lens (house-machine-10#episodes), and the viewer shows the whole chain.

A series runs several chats end to end, as one chat: series:<glob> takes every chat whose name matches (series:house-machine-*), and series:<glob>@<lens> takes each one's lens of that name instead, skipping chats that have none. A series is outlined, read and cited like a chat (its addresses are its chats' own), so a lens over a series (series:house-machine-*@storyline#arc) tells the story across them.

The description every chat-reading tool gives its chat parameter.

private const val CHAT_PARAM = "chat name from chatlog.list; <chat>#<lens> for a lens; series:<glob> for several chats end to end " +
    "(series:house-machine-*), or series:<glob>@<lens> for each one's lens of that name"

fun Box.registerChatlogTools(vararg roots: File, lensDir: File? = null)

body — 115 lines, from line 44
fun Box.registerChatlogTools(vararg roots: File, lensDir: File? = null) {
    val store = ChatlogStore(roots.toList(), lensDir ?: File(inside.filename, "lenses"))
    val owner = this
    ChatLensViewer.register(inside.filename, store)

    aiTool("chatlog.list") {
        describe("List the saved AI4 chats in the archive, with their size: turns, replies, tool calls, images, and a text-token estimate.")
        action {
            store.all().joinToString("\n") { (id, tree) ->
                val s = ChatStats.of(tree)
                val words = tree.nodes.flatMap { it.blocks }.filter { it.role == "you" || it.role == "ai" }.sumOf { it.text.length }
                "$id · ${s.userTurns} turns (${s.abandonedUserTurns} rewound) · ${s.replies} replies · ${s.toolCalls + s.programCalls} tool calls · " +
                    "${s.toolImages} images · ~${words / 4} tokens of conversation" +
                    store.lenses(id).takeIf { it.isNotEmpty() }?.let { names -> " · lenses: " + names.joinToString { "$id#$it" } }.orEmpty()
            }.ifBlank { "No chats under ${store.roots.joinToString()}." }
        }
    }

    aiTool("chatlog.outline") {
        describe(
            "The map of one chat: every user turn with its time and full request, then what followed, each with its address: " +
                "replies as their first line, tool activity as round lines (calls, time, failures, notes, edits, looks), images as captions; " +
                "then abandoned (rewound) branches. Start here."
        )
        val chat by param<String>(CHAT_PARAM)
        action { ChatLogViews.outline(store.tree(chat())) }
    }

    aiTool("chatlog.read") {
        describe(
            "Read a stretch of a chat as text, each block headed by its [address]. Omit from/to for the whole current branch. " +
                "detail=text gives requests and replies in full and tool activity as round lines; detail=full adds each tool block's text. " +
                "If the page stops early it says where to continue."
        )
        val chat by param<String>(CHAT_PARAM)
        val from by param<String>("first block address, or empty for the start", default = "")
        val to by param<String>("last block address, or empty for the end", default = "")
        val detail by param<String>("text or full", default = "text")
        val maxChars by param<Int>("page size in characters (at most 40000)", default = 20000)
        action {
            val page = ChatLogViews.read(store.tree(chat()), from().ifBlank { null }, to().ifBlank { null }, detail(), maxChars().coerceIn(2000, 40_000))
            page.text + (page.next?.let { "\n…continues: read again with from=$it" } ?: "")
        }
    }

    aiTool("chatlog.search") {
        describe("Find blocks on any branch whose text contains a phrase, optionally only blocks with tool calls of one family (notes, recipes, design, builds, looking, reading, computing).")
        val chat by param<String>(CHAT_PARAM)
        val query by param<String>("phrase to find (case-insensitive); empty with a family lists that family's blocks", default = "")
        val family by param<String>("tool family, or empty", default = "")
        action {
            ChatLogViews.search(store.tree(chat()), query(), family().ifBlank { null })
                .joinToString("\n") { "[${it.address}] ${it.kind}: ${it.snippet}" }.ifBlank { "No matches." }
        }
    }

    aiTool("chatlog.images") {
        describe(
            "The tool images in a chat or a stretch of it: address, reference (ai4img:…), what the tool was looking for, and what the agent " +
                "concluded about it. view=true also shows you up to 4 of the pictures themselves."
        )
        val chat by param<String>(CHAT_PARAM)
        val from by param<String>("first block address, or empty", default = "")
        val to by param<String>("last block address, or empty", default = "")
        val view by param<Boolean>("also return the first few pictures as images", default = false)
        action {
            val tree = store.tree(chat())
            val entries = ChatLogViews.images(tree, from().ifBlank { null }, to().ifBlank { null })
            val text = entries.joinToString("\n") { e ->
                "[${e.address}] ${e.ref ?: "inline image"} — looking for: ${e.caption ?: "(no caption)"}\n    concluded: ${e.conclusion ?: "(nothing said)"}"
            }.ifBlank { "No images." }
            if (!view()) return@action text
            val urls = entries.mapNotNull { e -> e.ref?.let { store.dataUrl(chat(), it) } }.take(4)
            if (urls.isEmpty()) text else images(urls, text)
        }
    }

    aiTool("lens.write") {
        describe(
            "Save a lens: a shorter chat over this one, in chat markup: <<you>> for the user's words, <<ai>> for the agent's, and " +
                "<<summary>> for a block that condenses several turns of both at once. Every block cites the stretch it stands for " +
                "with a <<source:first..last>> line DIRECTLY UNDER its marker line (comma-separate several stretches), using addresses " +
                "from chatlog.outline or chatlog.read:\n<<summary>>\n<<source:t-a1.b0..t-a1.b9>>\nWhat happened, briefly.\n<<you>>\n" +
                "<<source:t-b2.b0>>\nThe next request.\nImages can be reused as ![caption](ai4img:…). Refused, with the reasons, when a " +
                "block cites nothing or a citation does not resolve. Writing the same name again replaces it."
        )
        val chat by param<String>(CHAT_PARAM)
        val name by param<String>("lens name, e.g. storyline")
        val markdown by param<String>("the lens, in chat markup")
        val brief by param<String>("the brief this lens answers, in a sentence or two", default = "")
        action {
            val tree = store.tree(chat())
            val problems = ChatLogViews.lensProblems(tree, markdown())
            if (problems.isNotEmpty()) return@action toolError(problems)
            val stats = ChatLogViews.lensStats(tree, markdown())
            owner.requireWritableChatLensDir(store.lensDir)
            val file = store.writeLens(chat(), name(), markdown(), brief(), stats)
            "Saved ${file.name}: ${stats["blocks"]} blocks citing ${stats["citedBlocks"]} of ${stats["chatBlocks"]} chat blocks, " +
                "${stats["compression"]}× shorter than what it cites."
        }
    }

    aiTool("lens.read") {
        describe("Read a saved lens and the brief it answers.")
        val chat by param<String>(CHAT_PARAM)
        val name by param<String>("lens name")
        action { store.readLens(chat(), name()) ?: toolError("No lens '${name()}' for ${chat()}; lens.list shows what exists.") }
    }

    aiTool("lens.list") {
        describe("List the lenses saved for a chat.")
        val chat by param<String>(CHAT_PARAM)
        action { store.lenses(chat()).joinToString("\n").ifBlank { "No lenses yet for ${chat()}." } }
    }
}

internal fun Box.requireWritableChatLensDir(destination: File)

A read-only document may export a lens elsewhere, but must not write one back into itself.

internal fun Box.requireWritableChatLensDir(destination: File) {
    val document = File(inside.filename).canonicalFile.toPath()
    if (destination.canonicalFile.toPath().startsWith(document) && ReadOnlyDocuments.blocksWritesFrom(this)) {
        ReadOnlyDocuments.refuseWrite("chat-lens", destination.path)
        throw IllegalStateException("This document is read-only")
    }
}

private val prettyJson = Json { prettyPrint = true }

class ChatlogStore(val roots: List<File>, val lensDir: File)

Finds chats under roots, keeps each tree until its files change, and keeps lenses under lensDir.

class ChatlogStore(val roots: List<File>, val lensDir: File) {
    private val cache = HashMap<String, Pair<Long, ChatTree>>()

    private fun chats(): List<Pair<String, ChatArchive.StoredChat>> {
        val all = roots.flatMap { root ->
            val docs = if (File(root, "metadata.edn").isFile) listOf(root)
                else (root.listFiles() ?: emptyArray()).filter { File(it, "metadata.edn").isFile }
            docs.sortedBy { it.name.replace(Regex("\\d+")) { m -> m.value.padStart(6, '0') } }.flatMap(ChatArchive::chats)
        }
        val perDoc = all.groupingBy { it.document }.eachCount()
        return all.map { c -> (if (perDoc[c.document] == 1) c.document else "${c.document}/${c.boxName ?: c.boxId}") to c }
    }

    private fun stamp(c: ChatArchive.StoredChat): Long =
        (c.documentDir.listFiles() ?: emptyArray()).filter { it.name.startsWith(c.boxId) || it.name == "metadata.edn" }.maxOfOrNull { it.lastModified() } ?: 0L

fun all(): List<Pair<String, ChatTree>> = chats().map { (id, c) -> id to treeOf(id, c) }

    private fun treeOf(id: String, c: ChatArchive.StoredChat): ChatTree {
        val s = stamp(c)
        cache[id]?.let { (at, tree) -> if (at == s) return tree }
        return ChatArchive.tree(c).also { cache[id] = s to it }
    }

    private fun stored(id: String): ChatArchive.StoredChat {
        val chats = chats()
        return chats.firstOrNull { it.first == id }?.second
            ?: chats.firstOrNull { (_, c) -> "${c.document}/${c.boxId}" == id }?.second
            ?: throw IllegalArgumentException("No chat '$id'. Chats: ${chats.joinToString { it.first }}")
    }

fun tree(id: String): ChatTree

The chat id; for <chat>#<lens> (any depth), that lens read as a chat; for series:…, a series.

    fun tree(id: String): ChatTree {
        if ('#' !in id && id.startsWith("series:")) return series(id)
        if ('#' !in id) return treeOf(id, stored(id))
        val below = id.substringBeforeLast('#')
        val name = id.substringAfterLast('#')
        val markdown = lensMarkdown(below, name)
            ?: throw IllegalArgumentException("No lens '$name' over $below; lens.list shows what exists.")
        // The chat at the bottom of the chain holds the pictures; a series's are in its members, and the
        // viewer's blob route looks in all of them ([blobFile]), so the first member stands in here.
        val root = id.substringBefore('#')
        val bottom = if (root.startsWith("series:")) stored(seriesMembers(root).first().substringBefore('#')) else stored(root)
        return ChatArchive.lensTree(bottom, "lens:$name", "$name lens over $below", markdown)
    }

fun seriesMembers(id: String): List<String>

The chats a series takes, in order: series:<glob> or series:<glob>@<lens>.

    fun seriesMembers(id: String): List<String> {
        val spec = id.removePrefix("series:")
        val glob = spec.substringBefore('@')
        val lens = spec.substringAfter('@', "").ifBlank { null }
        val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$glob")
        return chats().map { it.first }.filter { matcher.matches(java.nio.file.Paths.get(it)) }
            .filter { lens == null || lensMarkdown(it, lens) != null }
            .map { if (lens == null) it else "$it#$lens" }
    }

Several chats (or their lenses) as one: each member's current branch in turn, the first node of each hung from the last node of the one before, so a stretch can run across chats. Addresses are the members' own; lens nodes are renamed lens:<chat>#<lens> so two houses' storylines do not share addresses.

    private fun series(id: String): ChatTree {
        val members = seriesMembers(id)
        require(members.isNotEmpty()) { "series $id matches no chats (chats: ${chats().joinToString { it.first }})" }
        val nodes = mutableListOf<ChatNode>()
        for (member in members) {
            val tree = if ('#' in member) {
                val name = member.substringAfterLast('#')
                ChatArchive.lensTree(stored(member.substringBefore('#')), "lens:$member", "$name lens over ${member.substringBeforeLast('#')}",
                    lensMarkdown(member.substringBeforeLast('#'), name)!!)
            } else tree(member)
            tree.headPath.forEachIndexed { i, n ->
                nodes += n.copy(parent = if (i == 0) nodes.lastOrNull()?.id else n.parent, chat = member)
            }
        }
        return ChatTree("series", id, id, nodes.last().id, nodes)
    }

fun chain(id: String): List<String>

The chats of a chain from the top: a#b#c gives a#b#c, a#b, a.

    fun chain(id: String): List<String> = generateSequence(id) { it.takeIf { '#' in it }?.substringBeforeLast('#') }.toList()

fun dataUrl(id: String, ref: String): String?

An ai4img: reference as a data URL, from the chat's document blobs.

    fun dataUrl(id: String, ref: String): String? {
        val file = blobFile(id, ref.removePrefix("ai4img:")) ?: return null
        val type = file.extension.let { if (it == "jpg") "jpeg" else it }
        return "data:image/$type;base64," + Base64.getEncoder().encodeToString(file.readBytes())
    }

    private fun dirFor(id: String) = File(lensDir, id.replace(Regex("[/:*?\\\\]"), "_"))

    private fun safe(name: String): String {
        require(Regex("[A-Za-z0-9][A-Za-z0-9_-]{0,63}").matches(name)) { "lens name '$name': use letters, digits, '-' or '_'" }
        return name
    }

fun writeLens(id: String, name: String, markdown: String, brief: String, stats: Map<String, Any>): File

    fun writeLens(id: String, name: String, markdown: String, brief: String, stats: Map<String, Any>): File {
        val dir = dirFor(id).apply { mkdirs() }
        val md = File(dir, "${safe(name)}.md")
        md.writeText(markdown.trimEnd() + "\n")
        File(dir, "$name.json").writeText(prettyJson.encodeToString(buildJsonObject {
            put("chat", id)
            put("name", name)
            put("brief", brief)
            put("written", System.currentTimeMillis())
            put("model", System.getenv("AFIELD_AI_MODEL").orEmpty())
            put("stats", buildJsonObject { stats.forEach { (k, v) -> put(k, JsonPrimitive(v.toString())) } })
        }))
        return md
    }

fun readLens(id: String, name: String): String?

    fun readLens(id: String, name: String): String? {
        val dir = dirFor(id)
        val md = File(dir, "${safe(name)}.md").takeIf { it.isFile } ?: return null
        val record = File(dir, "$name.json").takeIf { it.isFile }?.readText().orEmpty()
        return "Record: $record\n\n${md.readText()}"
    }

fun lensMarkdown(id: String, name: String): String?

The lens itself, or null.

    fun lensMarkdown(id: String, name: String): String? = File(dirFor(id), "${safe(name)}.md").takeIf { it.isFile }?.readText()

fun lensRecord(id: String, name: String): kotlinx.serialization.json.JsonObject?

The record writeLens saved beside a lens (brief, model, stats), or null.

    fun lensRecord(id: String, name: String): kotlinx.serialization.json.JsonObject? =
        File(dirFor(id), "${safe(name)}.json").takeIf { it.isFile }
            ?.let { runCatching { Json.parseToJsonElement(it.readText()) as? kotlinx.serialization.json.JsonObject }.getOrNull() }

fun blobDir(id: String): File = stored(id.substringBefore('#')).blobDir

Where a chat's ai4img: pictures are.

fun blobFile(id: String, blob: String): File?

A picture by blob name for id; in a series, from whichever member chat holds it.

    fun blobFile(id: String, blob: String): File? {
        val root = id.substringBefore('#')
        val dirs = if (root.startsWith("series:")) seriesMembers(root).map { blobDir(it) } else listOf(blobDir(root))
        return dirs.map { File(it, blob) }.firstOrNull { it.isFile }
    }

fun lenses(id: String): List<String>

    fun lenses(id: String): List<String> =
        (dirFor(id).listFiles() ?: emptyArray()).filter { it.extension == "md" }.map { it.nameWithoutExtension }.sorted()
}