feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening

Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
This commit is contained in:
2026-07-10 11:13:43 +04:00
parent f51a8dada4
commit 3d5e05c1fb
32 changed files with 742 additions and 85 deletions
@@ -367,6 +367,7 @@ fun main() {
embedder = embedder,
l3MemoryStore = l3MemoryStore,
repoRoot = workspaceRoot.toString(),
eventStore = eventStore,
)
} else {
null
@@ -1,25 +1,74 @@
package com.correx.apps.server.memory
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RepoKnowledgeHit
import com.correx.core.events.events.RepoKnowledgeHitsFilteredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.inference.Embedder
import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever
import com.correx.core.talkie.l3.L3MemoryStore
import com.correx.core.talkie.l3.L3Query
import kotlinx.coroutines.CancellationException
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID
private val log = LoggerFactory.getLogger(L3RepoKnowledgeRetriever::class.java)
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
// Real embedder scores for genuinely relevant repo-map hits cluster 0.68-0.80 (2026-07-03
// live grounding); below this floor a hit is just the nearest thing in a small corpus, not
// actually related to the query — rendering it as "relevant" is the same poisoning failure
// mode as the markdown-in-L3 bug, just via low-signal cosine similarity instead of topic drift.
private const val MIN_SIMILARITY_SCORE = 0.5f
class L3RepoKnowledgeRetriever(
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
private val repoRoot: String,
private val eventStore: EventStore? = null,
) : RepoKnowledgeRetriever {
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
val vector = embedder.embed(query)
return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
// 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) }
val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE }
if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() })
return kept.take(k).map { it.toHit() }
}
private suspend fun recordDropped(sessionId: SessionId, query: String, dropped: List<RepoKnowledgeHit>) {
val store = eventStore ?: return
runCatching {
store.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = RepoKnowledgeHitsFilteredEvent(
sessionId = sessionId,
query = query,
threshold = MIN_SIMILARITY_SCORE,
dropped = dropped,
),
),
)
}.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("failed to record filtered repo-knowledge hits for {}: {}", sessionId.value, e.message)
}
}
private fun com.correx.core.talkie.l3.L3Hit.toHit() =
RepoKnowledgeHit(path = entry.text.substringBefore(":"), text = entry.text, score = score)
}
@@ -117,7 +117,14 @@ class ProjectMemoryService(
// 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 ->
// Docs are already surfaced via the "Docs available" catalog built straight from
// RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
// them into the same L3 namespace as code let generic textual similarity (e.g. "analyst",
// "compression pipeline") outrank the actual code files for a code-focused query, silently
// crowding the semantic "Relevant files" feed with irrelevant markdown (2026-07-08 recurrence
// of the 2026-07-05 context-poisoning root cause, this time via L3RepoKnowledgeRetriever
// rather than the repo-map floor that was fixed then).
entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
runCatching {
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
l3MemoryStore.store(
@@ -32,12 +32,12 @@ class RepoMapIndexer(
) : RepoMapIndexerPort {
override fun index(repoRoot: Path): List<RepoMapEntry> {
if (!repoRoot.isDirectory()) return emptyList()
val ignore = ignoreGlobs.toSet()
val ignore = ignoreGlobs.toSet() + loadCorrexIgnore(repoRoot)
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 } }
.filter { path -> !isIgnored(path.relativeTo(repoRoot), ignore) }
.toList()
}
if (files.isEmpty()) return emptyList()
@@ -58,6 +58,35 @@ class RepoMapIndexer(
.sortedByDescending { it.score }
}
/**
* A repo-local, gitignore-style opt-out for the repo map: patterns here apply on top of
* `[project] ignore_globs` (CorrexConfig) without needing a config edit, so anyone working in
* the repo can silence a noisy path for themselves. Same matching rules as [isIgnored] — one
* pattern per line, blank lines and `#` comments skipped. Missing file is not an error (most
* repos won't have one).
*/
private fun loadCorrexIgnore(repoRoot: Path): List<String> {
val file = repoRoot.resolve(".correxignore")
if (!file.isRegularFile()) return emptyList()
return runCatching { file.readText() }.getOrNull()
?.lines()
?.map { it.trim() }
?.filter { it.isNotEmpty() && !it.startsWith("#") }
?: emptyList()
}
/**
* An ignore entry matches either a bare path-segment name (any depth, e.g. "node_modules") or a
* relative path prefix (e.g. "examples/workflows/prompts", to exclude just that subtree).
*/
private fun isIgnored(relativePath: Path, ignore: Set<String>): Boolean {
val rel = relativePath.toString().replace('\\', '/')
return ignore.any { pattern ->
if ('/' in pattern) rel == pattern || rel.startsWith("$pattern/")
else relativePath.any { it.name == pattern }
}
}
private fun extractSymbols(path: Path): List<String> {
// Docs are indexed as a single "what is this" descriptor, not a table-of-contents dump: a
// heading flood (40 h1h3 per doc across a large docs/ tree) buries real source and force-feeds