diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt index 5aeef84c..57b738fb 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt @@ -17,7 +17,8 @@ class L3RepoKnowledgeRetriever( override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List { val vector = embedder.embed(query) return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) - .filter { it.entry.turnId.startsWith("repomap:$repoRoot") } + // Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision). + .filter { it.entry.turnId.startsWith("repomap:$repoRoot:") } .take(k) .map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt index faaf32a1..cb449a67 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt @@ -85,7 +85,9 @@ class ProjectMemoryService( ), ), ) - val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot" + // Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling + // (/repo vs /repo2) under the retriever's startsWith filter. + val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:" entries.forEach { entry -> runCatching { val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}" diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt new file mode 100644 index 00000000..8db9c7d5 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt @@ -0,0 +1,42 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.types.SessionId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.InMemoryL3MemoryStore +import com.correx.core.router.l3.L3MemoryEntry +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +private class RetrieverConstantEmbedder(override val dimension: Int = 4) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 0.5f } +} + +class L3RepoKnowledgeRetrieverTest { + + private val vec = FloatArray(4) { 0.5f } + + @Test + fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking { + val l3 = InMemoryL3MemoryStore() + l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L)) + l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L)) + + val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo") + .retrieve(SessionId("s"), "anything", 10) + + assertEquals(listOf("repo/A.kt: Foo"), hits.map { it.text }, "must not leak /repo2 entries into /repo") + } + + @Test + fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking { + val l3 = InMemoryL3MemoryStore() + l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L)) + l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L)) + + val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo") + .retrieve(SessionId("s"), "anything", 10) + + assertEquals(setOf("repo/A.kt", "repo/B.kt"), hits.map { it.text }.toSet()) + } +}