Skip to content

AttachmentExcerpt.kt

src/jvmMain/kotlin/afield/ai/tools/AttachmentExcerpt.kt · 67 lines

package afield.ai.tools

import java.security.MessageDigest

Reading a long attachment one window at a time, under the guards every attachment shares.

The slice is taken after ordinary resolution, so an attachment already in the turn's context and one the document's own resolver fetches are treated alike. The window is bounded (2 to 65536 characters) and has to start inside the content. The content's SHA-256 comes back as sourceHash, and a later read that passes it back is refused when the source has changed since, with a message asking for a fresh read from the start rather than a splice of two versions. A find string moves the window to the first match at or after the offset, with a little room before it, and an empty window with searchMatched false says there was none. An offset that would split a surrogate pair is refused, and a computed start or end that would is moved back one, so a window never ends mid-character. nextOffset is null on the last one.

internal fun AttachmentResponse.excerpt(request: AttachmentRequest): AttachmentResponse

Slice after ordinary resolution so attachments, property refs and resolver paths share guards.

internal fun AttachmentResponse.excerpt(request: AttachmentRequest): AttachmentResponse {
    require(request.maxChars in 2..65536) { "maxChars must be between 2 and 65536" }
    require(request.offset in 0..content.length) { "offset must be between 0 and ${content.length}" }
    val hash = MessageDigest.getInstance("SHA-256").digest(content.toByteArray(Charsets.UTF_8))
        .joinToString("") { "%02x".format(it) }
    require(request.expectedSourceHash.isEmpty() || request.expectedSourceHash == hash) {
        "Source changed since the previous read. Read again from offset 0 and reconcile; current sourceHash=$hash"
    }
    fun splitsPair(at: Int) = at > 0 && at < content.length &&
        content[at - 1].isHighSurrogate() && content[at].isLowSurrogate()
    require(!splitsPair(request.offset)) { "offset splits a Unicode character; use the previous nextOffset" }
    // From offset, then from the start: a model that guessed an offset past the text (2026-10-28, offset
    // 8000 for a declaration at 3054) got an empty excerpt for text the file does contain.
    val match = request.find.takeIf { it.isNotEmpty() }?.let { find ->
        content.indexOf(find, request.offset).takeIf { it >= 0 } ?: content.indexOf(find)
    }
    fun lineAt(at: Int) = 1 + (0 until at).count { content[it] == '\n' }
    if (match == -1) return copy(content = "", offset = request.offset, totalChars = content.length,
        sourceHash = hash, nextOffset = null, isExcerpt = true, searchMatched = false, matchOffset = null,
        startLine = lineAt(request.offset), nearMatches = nearMatches(content, request.find))
    var start = if (match != null) (match - minOf(1000, request.maxChars / 4)).coerceAtLeast(minOf(request.offset, match)) else request.offset
    if (splitsPair(start)) start--
    var end = (start.toLong() + request.maxChars).coerceAtMost(content.length.toLong()).toInt()
    if (splitsPair(end)) end--
    return copy(
        content = content.substring(start, end), offset = start,
        totalChars = content.length, sourceHash = hash,
        nextOffset = end.takeIf { it < content.length },
        isExcerpt = start != 0 || end != content.length,
        searchMatched = match?.let { true }, matchOffset = match,
        startLine = lineAt(start), matchLine = match?.let(::lineAt)
    )
}

On 2026-10-28 a local model asked for fun drawn( (the declaration is fun Shape2.drawn() and then guessed penFor(, getting an empty excerpt each time. The longest identifier in the search text usually survives the misremembering, so show where it does occur.

private fun nearMatches(content: String, find: String, limit: Int = 8): List<String> {
    val word = Regex("[A-Za-z_][A-Za-z0-9_]{2,}").findAll(find).map { it.value }.maxByOrNull { it.length } ?: return emptyList()
    return content.lineSequence().withIndex()
        .filter { (_, line) -> line.contains(word) }
        .take(limit)
        .map { (i, line) -> "${i + 1}: ${line.trim().take(160)}" }
        .toList()
}