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:
@@ -367,6 +367,7 @@ fun main() {
|
||||
embedder = embedder,
|
||||
l3MemoryStore = l3MemoryStore,
|
||||
repoRoot = workspaceRoot.toString(),
|
||||
eventStore = eventStore,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
+52
-3
@@ -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 h1–h3 per doc across a large docs/ tree) buries real source and force-feeds
|
||||
|
||||
@@ -89,6 +89,22 @@ type EventEntry struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// PlanStageStatus tracks a stage's lifecycle in a locked execution plan.
|
||||
type PlanStageStatus int
|
||||
|
||||
const (
|
||||
PlanPending PlanStageStatus = iota
|
||||
PlanRunning
|
||||
PlanCompleted
|
||||
PlanFailed
|
||||
)
|
||||
|
||||
// PlanStage is one stage in a locked execution plan, with live execution status.
|
||||
type PlanStage struct {
|
||||
ID string
|
||||
Status PlanStageStatus
|
||||
}
|
||||
|
||||
// ToolStatus mirrors the Kotlin ToolDisplayStatus.
|
||||
type ToolStatus int
|
||||
|
||||
@@ -194,6 +210,8 @@ type Session struct {
|
||||
Name string
|
||||
LastEventAt int64
|
||||
CurrentStage string
|
||||
PlanGoal string
|
||||
PlanStages []PlanStage
|
||||
WorkspaceRoot string // bound cwd, from session.workspace_bound
|
||||
LastOutput string
|
||||
LastResponse string
|
||||
|
||||
@@ -612,9 +612,11 @@ var nonRenderingEventTypes = map[string]string{
|
||||
protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render",
|
||||
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
|
||||
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
|
||||
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
|
||||
protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript",
|
||||
protocol.TypeHealthChecks: "query reply: populates the health-checks overlay, not the transcript",
|
||||
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
|
||||
protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript",
|
||||
protocol.TypeHealthChecks: "query reply: populates the health-checks overlay, not the transcript",
|
||||
protocol.TypeProjectProfile: "query reply: populates the project profile overlay, not the transcript",
|
||||
protocol.TypeOperatorProfile: "query reply: populates the operator profile overlay, not the transcript",
|
||||
}
|
||||
|
||||
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
|
||||
|
||||
@@ -153,16 +153,34 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.Tools = nil
|
||||
s.StageTokensUsed = 0
|
||||
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
|
||||
for i := range s.PlanStages {
|
||||
if s.PlanStages[i].ID == msg.StageID {
|
||||
s.PlanStages[i].Status = PlanRunning
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case protocol.TypeStageCompleted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.CurrentStage = ""
|
||||
s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID)
|
||||
for i := range s.PlanStages {
|
||||
if s.PlanStages[i].ID == msg.StageID {
|
||||
s.PlanStages[i].Status = PlanCompleted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case protocol.TypeStageFailed:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.CurrentStage = ""
|
||||
s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID)
|
||||
for i := range s.PlanStages {
|
||||
if s.PlanStages[i].ID == msg.StageID {
|
||||
s.PlanStages[i].Status = PlanFailed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case protocol.TypeInferenceStarted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
@@ -303,6 +321,11 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.addEvent(nowMillis(), "PlanLocked", strings.Join(msg.StageIDs, " → "))
|
||||
s.LastOutput = "plan locked: " + strings.Join(msg.StageIDs, " → ")
|
||||
s.PlanGoal = msg.Content
|
||||
s.PlanStages = make([]PlanStage, 0, len(msg.StageIDs))
|
||||
for _, id := range msg.StageIDs {
|
||||
s.PlanStages = append(s.PlanStages, PlanStage{ID: id, Status: PlanPending})
|
||||
}
|
||||
}
|
||||
case protocol.TypeWorkspaceBound:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
|
||||
@@ -790,6 +790,10 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) {
|
||||
t := m.theme
|
||||
msgs := m.routerMessages[m.selectedID]
|
||||
var rows []string
|
||||
// Prepend the live execution plan card when the session has locked plan stages.
|
||||
if planRows := m.planCardLines(); len(planRows) > 0 {
|
||||
rows = append(rows, planRows...)
|
||||
}
|
||||
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
|
||||
for mi, e := range msgs {
|
||||
msgStart[mi] = len(rows)
|
||||
@@ -847,6 +851,53 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) {
|
||||
return rows, msgStart
|
||||
}
|
||||
|
||||
// planCardLines renders the live execution plan card for the selected session.
|
||||
func (m Model) planCardLines() []string {
|
||||
t := m.theme
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || len(s.PlanStages) == 0 {
|
||||
return nil
|
||||
}
|
||||
goal := strings.TrimSpace(s.PlanGoal)
|
||||
var out []string
|
||||
if goal != "" {
|
||||
out = append(out, t.span(goal, t.P.Faint))
|
||||
}
|
||||
stageLabel := "stage"
|
||||
if len(s.PlanStages) != 1 {
|
||||
stageLabel = "stages"
|
||||
}
|
||||
out = append(out, t.span("Plan \u00b7 "+itoa(len(s.PlanStages))+" "+stageLabel, t.P.Accent))
|
||||
idW := 0
|
||||
for _, ps := range s.PlanStages {
|
||||
if len(ps.ID) > idW {
|
||||
idW = len(ps.ID)
|
||||
}
|
||||
}
|
||||
for _, ps := range s.PlanStages {
|
||||
var badge, badgeStyle string
|
||||
var badgeColor color.Color
|
||||
switch ps.Status {
|
||||
case PlanPending:
|
||||
badge = "\u25cb"
|
||||
badgeColor = t.P.Faint
|
||||
case PlanRunning:
|
||||
badge = "\u25cf"
|
||||
badgeColor = t.P.Accent
|
||||
case PlanCompleted:
|
||||
badge = "\u2713"
|
||||
badgeColor = t.P.OK
|
||||
case PlanFailed:
|
||||
badge = "\u2717"
|
||||
badgeColor = t.P.Bad
|
||||
}
|
||||
badgeStyle = t.span(badge, badgeColor)
|
||||
rest := t.span(" "+padRaw(ps.ID, idW), t.P.Fg)
|
||||
out = append(out, " "+badgeStyle+rest)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) eventRows(w, h int) []string {
|
||||
t := m.theme
|
||||
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
||||
|
||||
Reference in New Issue
Block a user