feat(server,kernel): repo-map indexer + droppable L3 context injection

This commit is contained in:
2026-06-04 01:48:05 +04:00
parent 0ca7d4c86b
commit 8b6eedcf87
9 changed files with 242 additions and 4 deletions
@@ -298,6 +298,10 @@ fun main() {
l3MemoryStore = l3MemoryStore,
journalRepository = decisionJournalRepository,
eventStore = eventStore,
indexer = com.correx.apps.server.memory.RepoMapIndexer(
maxDepth = correxConfig.project.maxDepth,
ignoreGlobs = correxConfig.project.ignoreGlobs,
),
)
} else {
null
@@ -129,8 +129,14 @@ class ServerModule(
) {
activeSessionJobs.computeIfAbsent(sessionId) {
moduleScope.launch {
// Seed prior-session project memory before the run so stages see it in context.
projectMemory?.let { pm -> runCatching { pm.retrieveAndSeed(sessionId, pm.repoRoot()) } }
// Record the repo map + seed prior-session memory before the run so stages
// see both in context.
projectMemory?.let { pm ->
runCatching {
pm.indexAndRecord(sessionId, pm.repoRoot())
pm.retrieveAndSeed(sessionId, pm.repoRoot())
}
}
runCatching {
orchestrator.run(sessionId, graph, sessionConfig)
// Distil this run's decisions into durable project memory on completion.
@@ -3,6 +3,7 @@ package com.correx.apps.server.memory
import com.correx.core.config.ProjectConfig
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
@@ -39,12 +40,47 @@ class ProjectMemoryService(
private val journalRepository: DefaultDecisionJournalRepository,
private val eventStore: EventStore,
private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
private val indexer: RepoMapIndexer = RepoMapIndexer(),
) {
private fun tag(repoRoot: String) = "project:$repoRoot"
/** Resolved repo-root key: configured [ProjectConfig.root], else the working dir. */
fun repoRoot(): String = config.root.ifBlank { System.getProperty("user.dir") ?: "." }
/**
* Walk [repoRoot] and record the ranked file/symbol map as a [RepoMapComputedEvent] once
* per session. The full map is recorded (the log); only a top-K slice is injected into
* context downstream. Replay reads the recorded event, never re-scanning (invariant #9).
*/
suspend fun indexAndRecord(sessionId: SessionId, repoRoot: String) {
if (!config.enabled) return
runCatching {
val entries = indexer.index(java.nio.file.Path.of(repoRoot))
if (entries.isEmpty()) return
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = RepoMapComputedEvent(
sessionId = sessionId,
repoRoot = repoRoot,
entries = entries,
computedAt = Clock.System.now(),
),
),
)
}.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("repo-map indexing failed for {}: {}", repoRoot, e.message)
}
}
/** Distil the session's decision journal into durable, repo-tagged L3 entries. */
suspend fun persist(sessionId: SessionId, repoRoot: String) {
if (!config.enabled) return
@@ -0,0 +1,79 @@
package com.correx.apps.server.memory
import com.correx.core.events.events.RepoMapEntry
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.extension
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.readText
import kotlin.io.path.relativeTo
/**
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
* nondeterministic environment observation) — the caller records the result as a
* [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and
* never re-scans (invariant #9). Paths + top-level symbol names only, never file bodies.
*
* Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest
* ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank
* highest, which is what a fresh session most wants to see.
*/
class RepoMapIndexer(
private val maxDepth: Int = 4,
private val ignoreGlobs: List<String> = emptyList(),
) {
fun index(repoRoot: Path): List<RepoMapEntry> {
if (!repoRoot.isDirectory()) return emptyList()
val ignore = ignoreGlobs.toSet()
val files = Files.walk(repoRoot, maxDepth).use { stream ->
stream.filter { it.isRegularFile() }
.filter { it.extension in SYMBOL_PATTERNS.keys }
.filter { path -> path.relativeTo(repoRoot).none { it.name in ignore } }
.toList()
}
if (files.isEmpty()) return emptyList()
val mtimes = files.associateWith { runCatching { it.getLastModifiedTime().toMillis() }.getOrDefault(0L) }
val min = mtimes.values.min()
val max = mtimes.values.max()
val span = (max - min).coerceAtLeast(1L)
return files
.map { path ->
RepoMapEntry(
path = path.relativeTo(repoRoot).toString(),
score = (mtimes.getValue(path) - min).toDouble() / span,
symbols = extractSymbols(path),
)
}
.sortedByDescending { it.score }
}
private fun extractSymbols(path: Path): List<String> {
val regex = SYMBOL_PATTERNS[path.extension] ?: return emptyList()
val text = runCatching { path.readText() }.getOrElse { return emptyList() }
return regex.findAll(text)
.mapNotNull { it.groups["name"]?.value }
.distinct()
.take(MAX_SYMBOLS_PER_FILE)
.toList()
}
private companion object {
const val MAX_SYMBOLS_PER_FILE = 40
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
"kt" to Regex("""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"java" to Regex("""(?m)^\s*(?:public |private |protected |final |abstract |static )*(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"js" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
)
}
}
@@ -0,0 +1,60 @@
package com.correx.apps.server.memory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
import org.junit.jupiter.api.Test
class RepoMapIndexerTest {
@Test
fun `indexes source files with top-level symbols, ranks recent highest, prunes ignores`(@TempDir root: Path) {
root.resolve("Old.kt").writeText("class Old\nfun helper() {}\n")
Thread.sleep(10)
root.resolve("New.kt").writeText("interface New\nobject Thing\n")
// Pruned directory must not appear.
root.resolve("build").createDirectories()
root.resolve("build/Generated.kt").writeText("class Generated\n")
// Non-source file must be ignored.
root.resolve("README.md").writeText("# hi\n")
val entries = RepoMapIndexer(maxDepth = 4, ignoreGlobs = listOf("build")).index(root)
val paths = entries.map { it.path }
assertTrue(paths.contains("New.kt"))
assertTrue(paths.contains("Old.kt"))
assertFalse(paths.any { it.contains("build") }, "ignored dir leaked: $paths")
assertFalse(paths.contains("README.md"), "non-source file leaked")
// Most-recently-modified ranks first.
assertEquals("New.kt", entries.first().path)
val newEntry = entries.first { it.path == "New.kt" }
assertTrue(newEntry.symbols.containsAll(listOf("New", "Thing")))
assertTrue(entries.first { it.path == "Old.kt" }.symbols.contains("Old"))
}
@Test
fun `empty or missing root yields empty map`(@TempDir root: Path) {
assertTrue(RepoMapIndexer().index(root).isEmpty())
assertTrue(RepoMapIndexer().index(root.resolve("does-not-exist")).isEmpty())
}
@Test
fun `maxDepth bounds recursion`(@TempDir root: Path) {
root.resolve("a/b/c").createDirectories()
root.resolve("Top.kt").writeText("class Top\n")
root.resolve("a/b/c/Deep.kt").writeText("class Deep\n")
val shallow = RepoMapIndexer(maxDepth = 1).index(root).map { it.path }
assertTrue(shallow.contains("Top.kt"))
assertFalse(shallow.any { it.contains("Deep") }, "depth cap breached: $shallow")
}
}
@@ -418,6 +418,9 @@ object ConfigLoader {
enabled = asBoolean(projectSection["enabled"], false),
root = asString(projectSection["root"], ""),
memoryK = asInt(projectSection["memory_k"], 5),
maxDepth = asInt(projectSection["max_depth"], 4),
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
injectTopK = asInt(projectSection["inject_top_k"], 30),
)
val modelsSettings = ModelsSettings(
@@ -26,7 +26,16 @@ data class ProjectConfig(
val enabled: Boolean = false,
val root: String = "",
val memoryK: Int = 5,
)
val maxDepth: Int = 4,
val ignoreGlobs: List<String> = DEFAULT_IGNORES,
val injectTopK: Int = 30,
) {
companion object {
val DEFAULT_IGNORES: List<String> = listOf(
".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".opencode",
)
}
}
/**
* A user-declared artifact kind. [schemaPath] points to a JSON file holding the
@@ -30,6 +30,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
@@ -117,6 +118,7 @@ import kotlin.coroutines.cancellation.CancellationException
private const val MAX_TOOL_ROUNDS = 10
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val REPO_MAP_INJECT_TOP_K = 30
@SuppressWarnings(
"ForbiddenComment",
@@ -303,7 +305,9 @@ abstract class SessionOrchestrator(
role = EntryRole.SYSTEM,
),
)
var accumulatedEntries = systemPrompt + journalEntries + schemaEntries + promptEntries + steeringEntries
val repoMapEntries = buildRepoMapEntries(sessionId)
var accumulatedEntries =
systemPrompt + journalEntries + repoMapEntries + schemaEntries + promptEntries + steeringEntries
val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
@@ -807,6 +811,38 @@ abstract class SessionOrchestrator(
}
}
/**
* Renders a top-K slice of the recorded repo map as a droppable L3 context entry. Reads
* the latest [RepoMapComputedEvent] from the log (replay-safe — never re-scans the FS).
* Not pinned: under budget pressure this yields before the working set (paths + symbols
* only, so the model can `file_read` for detail).
*/
private suspend fun buildRepoMapEntries(sessionId: SessionId): List<ContextEntry> {
val map = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoMapComputedEvent }
.lastOrNull() ?: return emptyList()
val top = map.entries.sortedByDescending { it.score }.take(REPO_MAP_INJECT_TOP_K)
if (top.isEmpty()) return emptyList()
val content = buildString {
appendLine("## Repo map (top files by recency — read files for detail)")
top.forEach { entry ->
val symbols = if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
appendLine("- ${entry.path}$symbols")
}
}.trimEnd()
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L3,
content = content,
sourceType = "repoMap",
sourceId = "repo-map",
tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM,
),
)
}
private suspend fun emitToolArtifacts(
sessionId: SessionId,
stageId: StageId,
+5
View File
@@ -80,6 +80,11 @@ capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0
enabled = false
root = "" # repo root key; empty = current working dir
memory_k = 5 # distilled decisions retrieved per session start
# Repo-map indexer (emits a ranked file/symbol index as a recorded observation at session
# start; a top-K slice rides in context as droppable L3 memory — paths + symbol names only).
max_depth = 4 # directory recursion cap
inject_top_k = 30 # entries injected into context (ranked by score)
# ignore_globs = [".git", "node_modules", "build", "target", ".gradle", "dist", ".idea"]
# Router configuration (optional, defaults shown below)
[router]