feat(server,kernel): repo-map indexer + droppable L3 context injection
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user