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..32dae478 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,7 @@ 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") } + .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..f6a3bf99 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,7 @@ class ProjectMemoryService( ), ), ) - val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot" + 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/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt index 0ff8e2e9..a0b1ed69 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt @@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe { .toList() } val digest = MessageDigest.getInstance("SHA-256") - lines.forEach { digest.update(it.toByteArray()) } + digest.update(lines.joinToString("\n").toByteArray()) return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH) } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt index e5345b91..ae3e6652 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt @@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId import com.correx.infrastructure.persistence.InMemoryEventStore import kotlinx.coroutines.runBlocking 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.Test import java.nio.file.Path @@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest { "entries should be embedded with stateKey tag", ) } + + @Test + fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking { + val es = InMemoryEventStore() + val l3 = InMemoryL3MemoryStore() + val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false)) + val embedder = ConstantEmbedderReuse() + + // Index /repo and /repo2 into the same shared L3 store with the same stateKey. + // The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding + // across roots: "/repo" must NOT match tags written for "/repo2". + val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass"))) + val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class"))) + + service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo") + .indexAndRecord(SessionId("session-collide-repo"), "/repo") + service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2") + .indexAndRecord(SessionId("session-collide-repo2"), "/repo2") + + // existsByTurnIdPrefix with the delimiter must not match the other root. + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist") + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist") + + // The retriever scoped to /repo must return only /repo's entry, never /repo2's. + val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo") + .retrieve(SessionId("session-collide-query"), "anything", k = 10) + + assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries") + val text = hits.joinToString(" ") { it.text } + assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text") + assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text") + } }