feat(tui,server,config): live config editor + artifact viewer

Two operator features over the existing WebSocket protocol, plus a tui-go nav fix.

Config editor (g / palette "config"):
- core:config gains a thin registry stack: OrchestrationKnobs ([orchestration]
  block), CorrexConfigWriter (round-trip TOML serializer; parseToml(write(c))==c),
  ConfigHolder (AtomicReference live config), and EditableConfig (allowlist of
  editable fields + patch applier; security fields are absent so they can never
  be patched).
- ConfigService validates -> persists (atomic temp+move) -> swaps the holder ->
  rebuilds config-derived services. Live-apply is scoped to safe seams: stage
  timeout, compaction threshold (now a supplier), router knobs (routerFacade
  rebuild), and personalization/project toggles all take effect on the next
  session/turn; in-flight sessions keep the config they started with.
  server.host/port are persisted + badged "restart required", not hot-swapped.
- Protocol: GetConfig / UpdateConfig / ConfigSnapshot; TUI overlay with
  type-aware editing (bool toggle, enum cycle, inline int/string edit) and save.

Artifact viewer (v / palette "artifacts"):
- Server assembles a session's artifacts from the event log and resolves bytes
  from the CAS store (StreamQueries.listArtifacts) — a replay-neutral snapshot
  read. TUI overlay lists artifacts and shows scrollable content.

tui-go fix: workflow failure no longer clears selectedID (which yanked the user
to the list); listNav lands on the first session when nothing is selected
instead of wrapping to a random one.

Config is not event-sourced (it lives in TOML); editing stays outside the log.
This commit is contained in:
2026-06-09 10:18:35 +04:00
parent 89487db72a
commit a92b5a3531
27 changed files with 1629 additions and 72 deletions
@@ -10,15 +10,21 @@ import com.correx.core.journal.model.Salience
class JournalCompactionService(
private val artifactStore: ArtifactStore,
private val summarize: suspend (String) -> String,
val tokenThreshold: Int = 2000,
// Supplier (not a constant) so the threshold can track live config edits without rebuilding
// the orchestrator: it is read fresh on each compaction check.
private val tokenThreshold: () -> Int = { DEFAULT_TOKEN_THRESHOLD },
) {
companion object {
private const val DEFAULT_TOKEN_THRESHOLD = 2000
}
suspend fun compactIfNeeded(
sessionId: SessionId,
state: DecisionJournalState,
renderedTokenEstimate: Int,
emit: suspend (EventPayload) -> Unit,
): Boolean {
if (renderedTokenEstimate < tokenThreshold || state.records.isEmpty()) return false
if (renderedTokenEstimate < tokenThreshold() || state.records.isEmpty()) return false
val throughSequence = state.records.maxOf { it.sequence }
val highRecords = state.records.filter { it.kind.salience() == Salience.HIGH }
@@ -32,7 +32,7 @@ class JournalCompactionServiceTest {
@Test
fun `returns false when token estimate is below threshold`(): Unit = runBlocking {
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = { 2000 })
val state = stateWithRecords(makeRecord(1, DecisionKind.INTENT))
val emitted = mutableListOf<EventPayload>()
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { emitted += it }
@@ -42,7 +42,7 @@ class JournalCompactionServiceTest {
@Test
fun `returns false when records is empty even if estimate is high`(): Unit = runBlocking {
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = { 2000 })
val state = DecisionJournalState(records = emptyList())
val emitted = mutableListOf<EventPayload>()
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 9999) { emitted += it }
@@ -52,7 +52,7 @@ class JournalCompactionServiceTest {
@Test
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking {
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = 100)
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 })
val state = stateWithRecords(
makeRecord(1, DecisionKind.INTENT), // HIGH
makeRecord(2, DecisionKind.TRANSITION), // LOW
@@ -75,7 +75,7 @@ class JournalCompactionServiceTest {
val svc = JournalCompactionService(
artifactStore = fakeArtifactStore(),
summarize = { prompt -> capturedPrompts += prompt; "ok" },
tokenThreshold = 100,
tokenThreshold = { 100 },
)
val state = stateWithRecords(
makeRecord(1, DecisionKind.INTENT), // HIGH