fix(memory): trailing-delimiter turnId namespace + newline fingerprint
- repomap: tag prefix match requires trailing ':' so /repo can't match /repo2 (L3RepoKnowledgeRetriever filter + ProjectMemoryService write; the existing existsByTurnIdPrefix check already included the delimiter) - WorkspaceStateProbe fingerprint joins entries with newline to match docstring - regression test for /repo vs /repo2 non-collision Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -17,7 +17,7 @@ class L3RepoKnowledgeRetriever(
|
|||||||
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
|
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
|
||||||
val vector = embedder.embed(query)
|
val vector = embedder.embed(query)
|
||||||
return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
|
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)
|
.take(k)
|
||||||
.map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) }
|
.map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
entries.forEach { entry ->
|
||||||
runCatching {
|
runCatching {
|
||||||
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
|
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe {
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
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)
|
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId
|
|||||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest {
|
|||||||
"entries should be embedded with stateKey tag",
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user