GlslIncludeLibraryIndex.kt
src/jvmMain/kotlin/afield/ai/tools/GlslIncludeLibraryIndex.kt · 540 lines
package afield.ai.tools
import afield.runtime.lifecycle.RunsAt
import afield.ai.knowledge.FilestoreDir
import afield.ai.knowledge.Sha256
import afield.runtime.lifecycle.early
import afield.app.diagnostics.logging.lprintln
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.io.path.invariantSeparatorsPathString
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
GlslIncludeLibraryIndex scans directories of GLSL include files, Lygia by default plus any roots
named in AFIELD_GLSL_INCLUDE_DIRS or repositories in AFIELD_GLSL_INCLUDE_REPOS, and records each
file's include path, content hash and declared functions and macros in glsl_include_index.json
under the AI filestore, so an agent can find which include defines a function.
AFIELD_GLSL_INCLUDE_INDEX=off disables it and AFIELD_GLSL_INCLUDE_DEFAULTS=off drops the Lygia
default.
data class GlslIncludeSymbolRecord
One function or macro declared in an include file.
@JsonIgnoreProperties(ignoreUnknown = true)
data class GlslIncludeSymbolRecord(
val name: String,
val kind: String
)
data class GlslIncludeFileRecord
@JsonIgnoreProperties(ignoreUnknown = true)
data class GlslIncludeFileRecord(
val includePath: String,
val localPath: String,
val contentHash: String,
val bytes: Long,
val symbols: List<GlslIncludeSymbolRecord> = emptyList()
)
data class GlslIncludeLibraryRecord
@JsonIgnoreProperties(ignoreUnknown = true)
data class GlslIncludeLibraryRecord(
val name: String,
val includePrefix: String,
val root: String,
val source: String,
val gitUrl: String? = null,
val gitRef: String? = null,
val revision: String? = null,
val updatedAtEpochMs: Long,
val files: List<GlslIncludeFileRecord> = emptyList(),
val vectorStoreId: String? = null,
val bundleVersion: String? = null
)
data class GlslIncludeIndexDb
@JsonIgnoreProperties(ignoreUnknown = true)
data class GlslIncludeIndexDb(
val version: Int = 1,
val updatedAtEpochMs: Long = System.currentTimeMillis(),
val libraries: List<GlslIncludeLibraryRecord> = emptyList()
)
private data class GlslIncludeLibrarySpec(
val name: String,
val includePrefix: String,
val root: Path,
val source: String,
val gitUrl: String? = null,
val gitRef: String = "main",
val managed: Boolean = false
)
object GlslIncludeLibraryIndex
Indexes local or managed directories of GLSL include files for AI grounding.
Lygia is just the default managed instance of this generic mechanism. Users can add more include roots with AFIELD_GLSL_INCLUDE_DIRS.
object GlslIncludeLibraryIndex {
private const val LYGIA_URL = "https://github.com/patriciogonzalezvivo/lygia.git"
private val vendorDir: Path = Paths.get(System.getProperty("user.home"), ".afield", "vendor")
Resolved per call, not captured — see afield.ai.knowledge.FilestoreDir. vendorDir stays global on
purpose: it holds a multi-hundred-megabyte checkout of Lygia, which is the same for everyone and
is not an answer that two checkouts could disagree about.
private val indexPath: Path get() = FilestoreDir.resolve("glsl_include_index.json")
private val syncLock = Any()
var latestIndex: GlslIncludeIndexDb?
@Volatile
var latestIndex: GlslIncludeIndexDb? = null
private set
fun loadIndex(): GlslIncludeIndexDb?
fun loadIndex(): GlslIncludeIndexDb? {
if (!Files.exists(indexPath)) return null
return runCatching { OpenAiFileStoreCacheMapper.read(indexPath) }.getOrNull()
}
fun startBackgroundSync()
fun startBackgroundSync() {
if (!enabled()) {
lprintln("info,glsl.includes", "GLSL include indexing disabled")
return
}
thread(isDaemon = true, name = "GlslIncludeLibraryIndex") {
Thread.currentThread().priority = Thread.MIN_PRIORITY
synchronized(syncLock) {
runSync()
}
}
}
fun contextAttachmentFor(prompt: String, limit: Int = 24): ContextAttachment?
fun contextAttachmentFor(prompt: String, limit: Int = 24): ContextAttachment? {
val manifest = manifestFor(prompt, limit)
if (manifest.isBlank()) return null
return ContextAttachment(
fileName = "afield://glsl-includes/manifest",
language = "Markdown",
code = manifest
)
}
fun manifestFor(prompt: String, limit: Int = 24): String
body — 42 lines, from line 127
fun manifestFor(prompt: String, limit: Int = 24): String {
val index = latestIndex ?: loadIndex() ?: return ""
if (index.libraries.isEmpty()) return ""
val tokens = queryTokens(prompt)
val ranked = index.libraries.flatMap { lib ->
lib.files.map { file -> lib to file }
}.map { (lib, file) ->
Triple(scoreFile(tokens, file), lib, file)
}.filter { it.first > 0 || tokens.isEmpty() }
.sortedWith(
compareByDescending<Triple<Int, GlslIncludeLibraryRecord, GlslIncludeFileRecord>> { it.first }
.thenBy { it.third.includePath }
)
.take(limit.coerceAtLeast(1))
return buildString {
appendLine("GLSL include libraries are available to AI assistance.")
appendLine()
appendLine("Rules:")
appendLine("- Prefer targeted include directives over copying library code.")
appendLine("- Use include paths exactly as shown, for example: `#include \"lygia/generative/fbm.glsl\"`.")
appendLine("- If exact implementation details are needed, request the include path via AttachmentRequest.")
appendLine()
appendLine("Libraries:")
index.libraries.forEach { lib ->
val source = lib.gitUrl ?: lib.root
appendLine("- ${lib.name}: include prefix `${lib.includePrefix}/`, files=${lib.files.size}, source=$source")
}
if (ranked.isNotEmpty()) {
appendLine()
appendLine("Relevant include files:")
ranked.forEach { (_, _, file) ->
val symbols = file.symbols.take(8).joinToString(", ") { it.name }
if (symbols.isBlank()) {
appendLine("- `${file.includePath}`")
} else {
appendLine("- `${file.includePath}`: $symbols")
}
}
}
}.trimEnd()
}
fun resolveAttachment(raw: String): AttachmentResponse?
body — 365 lines, from line 170
fun resolveAttachment(raw: String): AttachmentResponse? {
val requested = normalizeIncludeRequest(raw) ?: return null
val index = latestIndex ?: loadIndex() ?: return null
for (lib in index.libraries) {
val file = lib.files.firstOrNull { it.includePath == requested } ?: continue
val path = Paths.get(file.localPath)
if (!Files.exists(path) || !path.isRegularFile()) continue
val content = runCatching { Files.readString(path, StandardCharsets.UTF_8) }.getOrNull() ?: continue
return AttachmentResponse(
file = file.includePath,
language = "GLSL",
content = buildString {
appendLine("// GLSL include: ${file.includePath}")
append(content)
}
)
}
return null
}
private fun runSync() {
val specs = configuredLibraries()
if (specs.isEmpty()) {
lprintln("info,glsl.includes", "no GLSL include libraries configured")
return
}
val previous = loadIndex()
val records = mutableListOf<GlslIncludeLibraryRecord>()
specs.forEach { spec ->
val root = prepareRoot(spec) ?: return@forEach
val rootString = root.toAbsolutePath().normalize().toString()
val revision = revisionFor(root)
val files = collectIncludeFiles(root, spec.includePrefix)
if (files.isEmpty()) {
lprintln("warning,glsl.includes", "no GLSL include files found for ${spec.name} at $root")
return@forEach
}
val bundleVersion = Sha256.hexUtf8(
buildString {
appendLine(spec.name)
appendLine(spec.includePrefix)
appendLine(revision ?: "")
files.forEach { appendLine("${it.includePath}:${it.contentHash}") }
}
)
records += GlslIncludeLibraryRecord(
name = spec.name,
includePrefix = spec.includePrefix,
root = rootString,
source = spec.source,
gitUrl = spec.gitUrl,
gitRef = spec.gitRef,
revision = revision,
updatedAtEpochMs = System.currentTimeMillis(),
files = files,
vectorStoreId = null,
bundleVersion = bundleVersion
)
}
val db = GlslIncludeIndexDb(
updatedAtEpochMs = System.currentTimeMillis(),
libraries = records
)
if (previous != null && previous.materiallySameAs(db)) {
latestIndex = previous
lprintln(
"info,glsl.includes",
"index unchanged: ${records.sumOf { it.files.size }} GLSL include files across ${records.size} libraries"
)
} else {
writeIndex(db)
latestIndex = db
lprintln(
"info,glsl.includes",
"indexed ${records.sumOf { it.files.size }} GLSL include files across ${records.size} libraries"
)
}
}
private fun GlslIncludeIndexDb.materiallySameAs(other: GlslIncludeIndexDb): Boolean =
version == other.version &&
libraries.map { it.copy(updatedAtEpochMs = 0L) } ==
other.libraries.map { it.copy(updatedAtEpochMs = 0L) }
private fun enabled(): Boolean =
!System.getenv("AFIELD_GLSL_INCLUDE_INDEX").equals("off", ignoreCase = true)
private fun mentionsIncludes(text: String): Boolean {
val lower = text.lowercase()
return listOf(
"lygia", "#include", "glsl include", "shader include", "sdf", "fbm", "noise",
"voronoi", "palette", "tonemap", "easing", "latticeboltzmann"
).any { it in lower }
}
private fun configuredLibraries(): List<GlslIncludeLibrarySpec> {
val specs = mutableListOf<GlslIncludeLibrarySpec>()
if (!System.getenv("AFIELD_GLSL_INCLUDE_DEFAULTS").equals("off", ignoreCase = true)) {
val overridePath = System.getenv("AFIELD_LYGIA_PATH")?.takeIf { it.isNotBlank() }?.let(::expandPath)
specs += GlslIncludeLibrarySpec(
name = "lygia",
includePrefix = "lygia",
root = overridePath ?: vendorDir.resolve("lygia"),
source = if (overridePath != null) "local" else "managed-git",
gitUrl = if (overridePath == null) LYGIA_URL else null,
gitRef = System.getenv("AFIELD_LYGIA_REF")?.takeIf { it.isNotBlank() } ?: "main",
managed = overridePath == null
)
}
parseLocalDirs().forEach { specs += it }
parseManagedRepos().forEach { specs += it }
return specs.distinctBy { it.name to it.root.toAbsolutePath().normalize().toString() }
}
private fun parseLocalDirs(): List<GlslIncludeLibrarySpec> {
val raw = System.getenv("AFIELD_GLSL_INCLUDE_DIRS")?.takeIf { it.isNotBlank() } ?: return emptyList()
return raw.split(File.pathSeparatorChar)
.mapNotNull { entry ->
val trimmed = entry.trim()
if (trimmed.isBlank()) return@mapNotNull null
val parts = trimmed.split("=", limit = 2)
val label = if (parts.size == 2) parts[0].trim() else ""
val path = expandPath(if (parts.size == 2) parts[1].trim() else trimmed)
val nameAndPrefix = label.takeIf { it.isNotBlank() } ?: path.fileName?.toString().orEmpty()
val (name, prefix) = parseNamePrefix(nameAndPrefix, path.fileName?.toString().orEmpty())
GlslIncludeLibrarySpec(
name = name,
includePrefix = prefix,
root = path,
source = "local",
managed = false
)
}
}
private fun parseManagedRepos(): List<GlslIncludeLibrarySpec> {
val raw = System.getenv("AFIELD_GLSL_INCLUDE_REPOS")?.takeIf { it.isNotBlank() } ?: return emptyList()
return raw.split(';')
.mapNotNull { entry ->
val trimmed = entry.trim()
if (trimmed.isBlank()) return@mapNotNull null
val parts = trimmed.split("=", limit = 2)
if (parts.size != 2) return@mapNotNull null
val (name, prefix) = parseNamePrefix(parts[0].trim(), "glsl")
val urlAndRef = parts[1].trim()
val url = urlAndRef.substringBefore("#")
val ref = urlAndRef.substringAfter("#", "main").ifBlank { "main" }
GlslIncludeLibrarySpec(
name = name,
includePrefix = prefix,
root = vendorDir.resolve(name),
source = "managed-git",
gitUrl = url,
gitRef = ref,
managed = true
)
}
}
private fun parseNamePrefix(label: String, fallback: String): Pair<String, String> {
val cleaned = label.trim().ifBlank { fallback }.ifBlank { "glsl" }
val parts = cleaned.split(":", limit = 2)
val name = sanitizeName(parts[0])
val prefix = sanitizeIncludePrefix(parts.getOrElse(1) { parts[0] })
return name to prefix
}
private fun prepareRoot(spec: GlslIncludeLibrarySpec): Path? {
val root = spec.root.toAbsolutePath().normalize()
if (!spec.managed) {
return if (Files.exists(root) && root.isDirectory()) root else null
}
val gitUrl = spec.gitUrl ?: return null
return try {
Files.createDirectories(root.parent)
if (!Files.exists(root.resolve(".git"))) {
lprintln("info,glsl.includes", "cloning ${spec.name} from $gitUrl to $root")
runGitCommand(null, "clone", "--branch", spec.gitRef, gitUrl, root.toString())
touchRefreshStamp(root)
} else if (shouldRefresh(root)) {
lprintln("info,glsl.includes", "refreshing managed GLSL include repo ${spec.name}")
runCatching { runGitCommand(root, "fetch", "origin") }
runCatching { runGitCommand(root, "pull", "--ff-only") }
touchRefreshStamp(root)
}
root
} catch (t: Throwable) {
lprintln("error,glsl.includes", "failed to prepare ${spec.name}: ${t.message}")
if (Files.exists(root) && root.isDirectory()) root else null
}
}
private fun shouldRefresh(root: Path): Boolean {
val stamp = root.resolve(".afield-last-refresh")
val last = runCatching { Files.getLastModifiedTime(stamp).toMillis() }.getOrDefault(0L)
val intervalMs = (System.getenv("AFIELD_GLSL_INCLUDE_REFRESH_HOURS")?.toLongOrNull() ?: 168L) * 60L * 60L * 1000L
return System.currentTimeMillis() - last > intervalMs
}
private fun touchRefreshStamp(root: Path) {
runCatching {
Files.writeString(root.resolve(".afield-last-refresh"), Instant.now().toString(), StandardCharsets.UTF_8)
}
}
private fun revisionFor(root: Path): String? =
runCatching {
if (!Files.exists(root.resolve(".git"))) return@runCatching null
runGitCommand(root, "rev-parse", "HEAD").trim().takeIf { it.isNotEmpty() }
}.getOrNull()
private fun runGitCommand(cwd: Path?, vararg args: String): String {
val command = listOf("git") + args
val process = ProcessBuilder(command)
.apply {
cwd?.let { directory(it.toFile()) }
redirectErrorStream(true)
}
.start()
val output = process.inputStream.bufferedReader().readText().trim()
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly()
throw IllegalStateException("git command timed out: ${command.joinToString(" ")}")
}
if (process.exitValue() != 0) {
throw IllegalStateException(
"git command failed (${process.exitValue()}): ${command.joinToString(" ")}\n$output"
)
}
return output
}
private fun collectIncludeFiles(root: Path, includePrefix: String): List<GlslIncludeFileRecord> {
if (!Files.exists(root)) return emptyList()
return Files.walk(root).use { stream ->
stream.filter { it.isRegularFile() }
.filter { path ->
val rel = root.relativize(path).invariantSeparatorsPathString
!rel.startsWith(".git/") &&
(rel.endsWith(".glsl", ignoreCase = true) ||
rel.equals("README.md", ignoreCase = true) ||
rel.equals("README_GLSL.md", ignoreCase = true) ||
rel.equals("EXAMPLES.md", ignoreCase = true) ||
rel.equals("DESIGN.md", ignoreCase = true))
}
.map { path ->
val rel = root.relativize(path).invariantSeparatorsPathString
val includePath = if (rel.endsWith(".glsl", ignoreCase = true)) {
"$includePrefix/$rel"
} else {
"$includePrefix/$rel"
}
val bytes = runCatching { Files.readAllBytes(path) }.getOrDefault(ByteArray(0))
val text = runCatching { bytes.toString(StandardCharsets.UTF_8) }.getOrDefault("")
GlslIncludeFileRecord(
includePath = includePath,
localPath = path.toAbsolutePath().normalize().toString(),
contentHash = Sha256.hex(bytes),
bytes = bytes.size.toLong(),
symbols = if (rel.endsWith(".glsl", ignoreCase = true)) extractSymbols(text) else emptyList()
)
}
.sorted(Comparator.comparing<GlslIncludeFileRecord, String> { it.includePath })
.toList()
}
}
private fun extractSymbols(text: String): List<GlslIncludeSymbolRecord> {
val symbols = linkedMapOf<String, GlslIncludeSymbolRecord>()
val functionRegex = Regex(
"""(?m)^\s*(?:[A-Za-z_][A-Za-z0-9_]*\s+)*[A-Za-z_][A-Za-z0-9_<>]*\s+([A-Za-z_][A-Za-z0-9_]*)\s*\("""
)
functionRegex.findAll(text).forEach { match ->
val name = match.groupValues[1]
if (name !in glslControlWords) symbols[name] = GlslIncludeSymbolRecord(name, "function")
}
val macroRegex = Regex("""(?m)^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)""")
macroRegex.findAll(text).forEach { match ->
val name = match.groupValues[1]
symbols.putIfAbsent(name, GlslIncludeSymbolRecord(name, "macro"))
}
return symbols.values.take(40)
}
private fun queryTokens(raw: String): Set<String> =
Regex("[A-Za-z_][A-Za-z0-9_]*")
.findAll(raw)
.map { it.value.lowercase() }
.filter { it.length >= 3 && it !in queryNoise }
.toSet()
private fun scoreFile(tokens: Set<String>, file: GlslIncludeFileRecord): Int {
if (tokens.isEmpty()) return 1
val path = file.includePath.lowercase()
val symbolNames = file.symbols.map { it.name.lowercase() }
var score = 0
tokens.forEach { token ->
if (path.contains(token)) score += 18
symbolNames.forEach { symbol ->
if (symbol == token) score += 45
else if (symbol.contains(token)) score += 25
}
}
return score
}
private fun normalizeIncludeRequest(raw: String): String? {
var s = raw.trim().trim('"', '\'', '`')
s = s.removePrefix("#include").trim().trim('<', '>', '"', '\'', '`')
if (s.startsWith("afield://glsl-includes/")) s = s.removePrefix("afield://glsl-includes/")
if (!s.endsWith(".glsl", ignoreCase = true) && !s.endsWith(".md", ignoreCase = true)) return null
return s.replace('\\', '/').removePrefix("/")
}
private fun writeIndex(db: GlslIncludeIndexDb) {
runCatching {
Files.createDirectories(indexPath.parent)
val tmp = indexPath.resolveSibling(indexPath.fileName.toString() + ".tmp")
Files.newBufferedWriter(tmp, StandardCharsets.UTF_8).use { writer ->
OpenAiFileStoreCacheMapper.mapper.writerWithDefaultPrettyPrinter().writeValue(writer, db)
}
Files.move(tmp, indexPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}.onFailure {
lprintln("error,glsl.includes", "failed to write index: ${it.message}")
}
}
private fun expandPath(raw: String): Path {
val home = System.getProperty("user.home")
return when {
raw == "~" -> Paths.get(home)
raw.startsWith("~/") || raw.startsWith("~\\") -> Paths.get(home).resolve(raw.drop(2))
else -> Paths.get(raw)
}.normalize()
}
private fun sanitizeName(raw: String): String =
raw.lowercase().replace(Regex("[^a-z0-9_.-]+"), "-").trim('-').ifBlank { "glsl" }
private fun sanitizeIncludePrefix(raw: String): String =
raw.replace('\\', '/').trim('/').ifBlank { sanitizeName(raw) }
private val glslControlWords = setOf(
"if", "for", "while", "switch", "return", "main", "defined"
)
private val queryNoise = setOf(
"glsl", "shader", "include", "includes", "field", "fragment", "vertex", "compute",
"mesh", "task", "void", "float", "vec2", "vec3", "vec4", "mat3", "mat4"
)
}
private object OpenAiFileStoreCacheMapper {
val mapper = afield.ai.knowledge.OpenAiFileStoreCache.defaultMapper()
fun read(path: Path): GlslIncludeIndexDb =
mapper.readValue(path.toFile(), GlslIncludeIndexDb::class.java)
}
fun syncGlslIncludeLibraryIndex()
@RunsAt(early, priority = 110)
fun syncGlslIncludeLibraryIndex() {
GlslIncludeLibraryIndex.startBackgroundSync()
}