feat(memory): state-keyed repo-map reuse via L3 presence check; embed entries on fresh scan
- Add existsByTurnIdPrefix(prefix) to L3MemoryStore interface; implemented in InMemoryL3MemoryStore (mutex-guarded) and TurboVecL3MemoryStore (metadata map). All test fakes (TrackingL3MemoryStore, CapturingStore) updated. - Introduce RepoMapIndexerPort interface; RepoMapIndexer implements it. Allows injection of test doubles without framework overhead. - ProjectMemoryService.indexAndRecord: reads latest WorkspaceStateObservedEvent from event store (invariant #9 — recorded fact, not re-probe); skips scan+embed when L3 already holds entries for "repomap:<root>:<stateKey>"; passes stateKey to RepoMapComputedEvent; embeds each scanned entry after append; per-entry failures warn-logged (CancellationException rethrown). - Tests: InMemoryL3MemoryStoreTest +2 cases; new ProjectMemoryServiceReuseTest covers same-key skip, changed-key re-index, no-observation always-reindex, and embedding turnId tag verification.
This commit is contained in:
@@ -41,7 +41,7 @@ class ProjectMemoryService(
|
||||
private val journalRepository: DefaultDecisionJournalRepository,
|
||||
private val eventStore: EventStore,
|
||||
private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
|
||||
private val indexer: RepoMapIndexer = RepoMapIndexer(),
|
||||
private val indexer: RepoMapIndexerPort = RepoMapIndexer(),
|
||||
private val workspaceStateProbe: WorkspaceStateProbe = RealWorkspaceStateProbe(),
|
||||
) {
|
||||
private fun tag(repoRoot: String) = "project:$repoRoot"
|
||||
@@ -56,6 +56,13 @@ class ProjectMemoryService(
|
||||
*/
|
||||
suspend fun indexAndRecord(sessionId: SessionId, repoRoot: String) {
|
||||
if (!config.enabled) return
|
||||
val stateKey = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? WorkspaceStateObservedEvent }
|
||||
.lastOrNull()?.stateKey
|
||||
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) {
|
||||
log.debug("repo-map already indexed for {}@{} — skipping", repoRoot, stateKey)
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
val entries = indexer.index(java.nio.file.Path.of(repoRoot))
|
||||
if (entries.isEmpty()) return
|
||||
@@ -73,10 +80,30 @@ class ProjectMemoryService(
|
||||
sessionId = sessionId,
|
||||
repoRoot = repoRoot,
|
||||
entries = entries,
|
||||
stateKey = stateKey,
|
||||
computedAt = Clock.System.now(),
|
||||
),
|
||||
),
|
||||
)
|
||||
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(", ")}"
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
sessionId = sessionId,
|
||||
turnId = tag,
|
||||
text = text,
|
||||
vector = embedder.embed(text),
|
||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||
),
|
||||
)
|
||||
}.exceptionOrNull()?.let { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("repo-map embedding failed for {}: {}", entry.path, e.message)
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()?.let { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("repo-map indexing failed for {}: {}", repoRoot, e.message)
|
||||
|
||||
@@ -11,6 +11,11 @@ import kotlin.io.path.name
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.relativeTo
|
||||
|
||||
/** Minimal port for repo-map scanning — allows injection of test doubles. */
|
||||
interface RepoMapIndexerPort {
|
||||
fun index(repoRoot: Path): List<RepoMapEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
|
||||
* nondeterministic environment observation) — the caller records the result as a
|
||||
@@ -24,8 +29,8 @@ import kotlin.io.path.relativeTo
|
||||
class RepoMapIndexer(
|
||||
private val maxDepth: Int = 4,
|
||||
private val ignoreGlobs: List<String> = emptyList(),
|
||||
) {
|
||||
fun index(repoRoot: Path): List<RepoMapEntry> {
|
||||
) : RepoMapIndexerPort {
|
||||
override fun index(repoRoot: Path): List<RepoMapEntry> {
|
||||
if (!repoRoot.isDirectory()) return emptyList()
|
||||
val ignore = ignoreGlobs.toSet()
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
import com.correx.core.config.ProjectConfig
|
||||
import com.correx.core.events.events.RepoMapEntry
|
||||
import com.correx.core.inference.Embedder
|
||||
import com.correx.core.journal.DecisionJournalProjector
|
||||
import com.correx.core.journal.DefaultDecisionJournalReducer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.router.l3.InMemoryL3MemoryStore
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Path
|
||||
|
||||
private class CountingIndexer(private val entries: List<RepoMapEntry>) : RepoMapIndexerPort {
|
||||
var callCount = 0
|
||||
override fun index(repoRoot: Path): List<RepoMapEntry> {
|
||||
callCount++
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
private class ConstantEmbedderReuse(override val dimension: Int = 4) : Embedder {
|
||||
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 0.5f }
|
||||
}
|
||||
|
||||
class ProjectMemoryServiceReuseTest {
|
||||
|
||||
private val entries = listOf(
|
||||
RepoMapEntry(path = "src/Foo.kt", score = 1.0, symbols = listOf("FooClass")),
|
||||
)
|
||||
|
||||
private fun service(
|
||||
es: InMemoryEventStore,
|
||||
l3: InMemoryL3MemoryStore,
|
||||
indexer: CountingIndexer,
|
||||
probe: WorkspaceStateProbe,
|
||||
root: String = "/repo",
|
||||
) = ProjectMemoryService(
|
||||
config = ProjectConfig(enabled = true, root = root),
|
||||
embedder = ConstantEmbedderReuse(),
|
||||
l3MemoryStore = l3,
|
||||
journalRepository = DefaultDecisionJournalRepository(
|
||||
DefaultEventReplayer(es, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
||||
),
|
||||
eventStore = es,
|
||||
indexer = indexer,
|
||||
workspaceStateProbe = probe,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same stateKey skips re-index on second session`(): Unit = runBlocking {
|
||||
val es = InMemoryEventStore()
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
val indexer = CountingIndexer(entries)
|
||||
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
|
||||
|
||||
val svc = service(es, l3, indexer, probe)
|
||||
val sessionA = SessionId("session-reuse-a")
|
||||
val sessionB = SessionId("session-reuse-b")
|
||||
|
||||
svc.observeAndRecord(sessionA, "/repo")
|
||||
svc.indexAndRecord(sessionA, "/repo")
|
||||
assertEquals(1, indexer.callCount, "first session should index")
|
||||
|
||||
svc.observeAndRecord(sessionB, "/repo")
|
||||
svc.indexAndRecord(sessionB, "/repo")
|
||||
assertEquals(1, indexer.callCount, "second session with same stateKey should skip index")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed stateKey re-indexes`(): Unit = runBlocking {
|
||||
val es = InMemoryEventStore()
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
val indexer = CountingIndexer(entries)
|
||||
|
||||
val svc1 = service(es, l3, indexer, FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false)))
|
||||
val sessionA = SessionId("session-change-a")
|
||||
svc1.observeAndRecord(sessionA, "/repo")
|
||||
svc1.indexAndRecord(sessionA, "/repo")
|
||||
assertEquals(1, indexer.callCount)
|
||||
|
||||
val svc2 = service(es, l3, indexer, FakeWorkspaceStateProbe(WorkspaceState("git:hash2", "git", "main", false)))
|
||||
val sessionB = SessionId("session-change-b")
|
||||
svc2.observeAndRecord(sessionB, "/repo")
|
||||
svc2.indexAndRecord(sessionB, "/repo")
|
||||
assertEquals(2, indexer.callCount, "changed stateKey should trigger re-index")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no observation event always re-indexes`(): Unit = runBlocking {
|
||||
val es = InMemoryEventStore()
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
val indexer = CountingIndexer(entries)
|
||||
val probe = FakeWorkspaceStateProbe(null)
|
||||
|
||||
val svc = service(es, l3, indexer, probe)
|
||||
svc.indexAndRecord(SessionId("session-noobs-1"), "/repo")
|
||||
svc.indexAndRecord(SessionId("session-noobs-2"), "/repo")
|
||||
assertEquals(2, indexer.callCount, "no observation event -> always re-index")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries are embedded with stateKey turnId tag after fresh index`(): Unit = runBlocking {
|
||||
val es = InMemoryEventStore()
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
val indexer = CountingIndexer(entries)
|
||||
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
|
||||
|
||||
val svc = service(es, l3, indexer, probe)
|
||||
val sessionId = SessionId("session-embed-1")
|
||||
svc.observeAndRecord(sessionId, "/repo")
|
||||
svc.indexAndRecord(sessionId, "/repo")
|
||||
|
||||
assertTrue(
|
||||
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"),
|
||||
"entries should be embedded with stateKey tag",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user