feat(context,kernel): compression pipeline stage 3 — replay-safe tier summarizer

ContextSummarizedEvent (registered) keyed on stable session sequence, not the
ephemeral pack ordinal, so replay reuses the recorded summary artifact and never
re-calls the LLM (invariants #6/#8). TierContextSummarizer mirrors
JournalCompactionService for freeform context (tool outputs/docs/retrieved text)
the journal path doesn't cover. ContextState + DefaultContextReducer fold the
event; reducer replay-safety unit-tested.
This commit is contained in:
2026-07-01 14:03:47 +04:00
parent b50688df1d
commit e0c222392c
6 changed files with 151 additions and 1 deletions
@@ -0,0 +1,64 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.model.ContextEntry
import com.correx.core.events.events.ContextSummarizedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
/**
* Stage 5 (RECURSIVE_SUMMARIZE) for freeform context — the tier the decision-journal
* compaction path does NOT cover: long tool outputs, docs and retrieved text. Mirrors
* [JournalCompactionService]: summarize via injected inference, store the summary in the
* artifact store, emit [ContextSummarizedEvent] referencing it. Replay reads the recorded
* artifact and never re-calls the LLM (invariants #6/#8).
*/
class TierContextSummarizer(
private val artifactStore: ArtifactStore,
private val summarize: suspend (String) -> String,
private val tokenThreshold: () -> Int = { DEFAULT_TOKEN_THRESHOLD },
) {
companion object {
private const val DEFAULT_TOKEN_THRESHOLD = 2000
private const val CHARS_PER_TOKEN = 4
}
/**
* Summarize [freeformEntries] (tier 2/3 candidates) when their combined size crosses the
* threshold. Returns true when a summary event was emitted. [tier] selects light summary
* (2) vs summary-of-summaries (3); protected spans are already preserved in the fact sheet,
* so the prompt only asks to keep intent and load-bearing facts.
*/
suspend fun summarizeIfNeeded(
sessionId: SessionId,
freeformEntries: List<ContextEntry>,
coversThroughSequence: Long,
tier: Int,
emit: suspend (EventPayload) -> Unit,
): Boolean {
if (freeformEntries.isEmpty()) return false
val tokenEstimate = freeformEntries.sumOf { it.content.length } / CHARS_PER_TOKEN
if (tokenEstimate < tokenThreshold()) return false
val prompt = buildString {
appendLine("Summarize the following context concisely (≤200 words).")
appendLine("Preserve user intent, decisions, constraints and every load-bearing fact.")
appendLine()
freeformEntries.forEach { appendLine("- ${it.content}") }
}
val summaryText = summarize(prompt)
val summaryArtifactId = artifactStore.put(summaryText.toByteArray(Charsets.UTF_8))
artifactStore.flushBefore {
emit(
ContextSummarizedEvent(
sessionId = sessionId,
coversThroughSequence = coversThroughSequence,
summaryArtifactId = summaryArtifactId,
tier = tier,
entriesOmittedCount = freeformEntries.size,
),
)
}
return true
}
}