Ai4KotlinAutocomplete.kt
src/jvmMain/kotlin/afield/ai/completion/Ai4KotlinAutocomplete.kt · 186 lines
package afield.ai.completion
Language-model autocomplete for box Kotlin code.
Ai4KotlinAutocomplete.complete marks the cursor in the code, wraps it as a Kotlin script and asks
a fast model for three short candidates, each with a one-line note, returned as
KotlinCompletionCandidates. One model thread is kept per file key, so later requests in the same
editor reuse the conversation. The Kotlin counterpart of Ai4GlslAutocomplete.
The model is AFIELD_KOTLIN_AUTOCOMPLETE_MODEL, defaulting to the fast model.
package and imports — 86 lines, from line 14
import afield.ai.models.LlmBridge
import afield.ai.models.LlmReasoningEffort
import afield.ai.models.LlmThreadState
import afield.ai.models.aiProviderConfigRevision
import afield.ai.models.createLlmBridge
import afield.ai.models.Ai4ModelDefaults
import afield.runtime.languages.KOTLIN_SCRIPT
import afield.ai.diagnostics.SilentLog
import java.util.concurrent.ConcurrentHashMap
private fun kotlinCompletionThreadInstructions(): String = """
You are an IDE autocomplete engine for Kotlin.
Rules:
- Output 3 candidate completions separated by a line containing only: ---
- Each candidate is ONLY the text to insert at <cursor>.
- After the candidates, output a notes block starting with the line: <<<notes>>>
- In the notes block, provide brief notes for candidates 1, 2, and 3 (one line each).
- Do not include markdown or backticks.
- Do not repeat existing code around the cursor.
- Keep each candidate short (a few lines max) and syntactically valid.
- Prefer helpful completions over empty output.
- Do not call tools; output plain text only.
""".trimIndent()
private fun kotlinCompletionPrompt(
line: Int,
column: Int,
codeWithCursor: String
): String = """
Return 3 completion candidates separated by a line containing only: ---
Then include a notes block that starts with: <<<notes>>>
Format the notes as:
1) ...
2) ...
3) ...
Cursor: line $line, column $column
Kotlin script:
$codeWithCursor
""".trimIndent()
private fun cleanCompletionText(raw: String): String {
var text = raw.trim()
if (text.startsWith("```")) {
val lines = text.lines()
val fenceEnd = lines.indexOfLast { it.trim().startsWith("```") }
if (fenceEnd > 0) {
text = lines.subList(1, fenceEnd).joinToString("\n").trim()
}
}
return text.replace("<cursor>", "")
.replace("<rewrite>", "")
.replace("</rewrite>", "")
.replace("<help>", "")
.replace("<error>", "")
.replace("</error>", "")
.trimEnd()
}
private fun splitCompletionCandidates(text: String): List<String> {
val results = mutableListOf<String>()
val current = StringBuilder()
val lines = text.split("\n")
for (line in lines) {
if (line.trim() == "---") {
val candidate = current.toString().trim()
if (candidate.isNotEmpty()) results += candidate
current.clear()
continue
}
if (current.isNotEmpty()) current.append("\n")
current.append(line)
}
val candidate = current.toString().trim()
if (candidate.isNotEmpty()) results += candidate
return results
}
private fun splitNotesBlock(text: String): Pair<String, String> {
val marker = Regex("(?i)<<<notes>>>")
val match = marker.find(text) ?: return text.trimEnd() to ""
val candidatesText = text.substring(0, match.range.first).trimEnd()
val notesText = text.substring(match.range.last + 1).trim()
return candidatesText to notesText
}
data class KotlinCompletionCandidate
data class KotlinCompletionCandidate(
val text: String,
val documentation: String
)
private fun parseCandidateDocs(commentary: String, count: Int): List<String> {
if (count <= 0) return emptyList()
val docs = MutableList(count) { "" }
if (commentary.isBlank()) return docs
val regex = Regex("^\\s*(\\d+)[).:-]?\\s*(.*)$")
var current = -1
commentary.lines().forEach { line ->
val match = regex.matchEntire(line)
if (match != null) {
val idx = match.groupValues[1].toIntOrNull()?.minus(1) ?: -1
if (idx in 0 until count) {
current = idx
docs[idx] = match.groupValues[2].trim()
return@forEach
}
}
if (current >= 0) {
val existing = docs[current]
docs[current] = if (existing.isBlank()) line.trim() else (existing + "\n" + line.trim()).trim()
}
}
if (docs.any { it.isNotBlank() }) return docs
val fallback = commentary.trim()
return List(count) { fallback }
}
object Ai4KotlinAutocomplete
object Ai4KotlinAutocomplete {
@Volatile private var bridgeRevision = -1L
@Volatile private var activeBridge: LlmBridge? = null
private val threadByKey = ConcurrentHashMap<String, LlmThreadState>()
@Synchronized
private fun bridge(): LlmBridge {
val revision = aiProviderConfigRevision()
activeBridge?.takeIf { bridgeRevision == revision }?.let { return it }
val fastModel = System.getenv("AFIELD_KOTLIN_AUTOCOMPLETE_MODEL")
?: Ai4ModelDefaults.fastModel()
return createLlmBridge(
model = fastModel,
log = SilentLog,
reasoningEffort = LlmReasoningEffort.LOW
).also {
threadByKey.clear()
activeBridge = it
bridgeRevision = revision
}
}
fun complete
fun complete(
fileKey: String,
code: String,
line: Int,
column: Int,
position: Int,
maxOutputTokens: Long = 320
): List<KotlinCompletionCandidate> {
val lang = KOTLIN_SCRIPT
val safePos = position.coerceIn(0, code.length)
val codeWithCursor = code.substring(0, safePos) + "<cursor>" + code.substring(safePos)
val wrapped = lang.wrap(codeWithCursor)
val bridge = bridge()
val state = threadByKey[fileKey] ?: bridge.startThread(
fileKey,
lang.wrap(code),
instructions = kotlinCompletionThreadInstructions()
).also { threadByKey[fileKey] = it }
val prompt = kotlinCompletionPrompt(line, column, wrapped)
val result = bridge.send(state, prompt, maxOutputTokens = maxOutputTokens)
val cleaned = cleanCompletionText(result.text)
val (candidatesText, notesText) = splitNotesBlock(cleaned)
val candidates = splitCompletionCandidates(candidatesText)
val fallbackNotes = result.commentary.joinToString("\n").trim()
val docs = parseCandidateDocs(if (notesText.isNotBlank()) notesText else fallbackNotes, candidates.size)
return candidates.mapIndexed { idx, text ->
KotlinCompletionCandidate(text, docs.getOrElse(idx) { "" })
}
}
}