feat(router): rubber-duck idea capture + feed

The rubber-duck path emits a fenced {"ideas":[…]} block; Ideas.parse strips it
from the chat turn and onUserInput records an IdeaCapturedEvent each (CHAT only).
IdeaReader folds eventStore.allEvents() into the active cross-session board, and
DefaultRouterContextBuilder injects a capped "## Ideas" entry — router-only, so
workflow stages never see it. Extended SYSTEM_PROMPT option (d) to capture ideas.
This commit is contained in:
2026-06-14 14:25:49 +04:00
parent 3ac0a140ec
commit 400ae5729a
8 changed files with 258 additions and 5 deletions
@@ -0,0 +1,37 @@
package com.correx.core.router
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.router.model.Idea
/**
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
*/
class IdeaReader(private val eventStore: EventStore) {
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
fun activeIdeas(): List<Idea> {
val discarded = mutableSetOf<String>()
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is IdeaDiscardedEvent -> discarded += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
else -> Unit
}
}
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
}
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
fun sessionOf(ideaId: String): SessionId? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
?.sessionId
}
@@ -0,0 +1,39 @@
package com.correx.core.router
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Parses an optional `ideas` block out of a router chat reply: the rubber-duck path may end its prose
* with a fenced ```` ```json {"ideas":["…","…"]} ``` ```` block to save ideas that crystallized.
* Lenient and fence-tolerant — scans every fenced block for the one carrying an `ideas` array, accepts
* string items or `{text|idea}` objects, and any parse failure or empty array yields no ideas. Returns
* the reply with that block stripped (so the operator reads only prose), mirroring [WorkflowProposals].
*/
object Ideas {
data class Parsed(val cleanText: String, val ideas: List<String>)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val fence = Regex("```(?:json)?\\s*(.*?)```", RegexOption.DOT_MATCHES_ALL)
fun parse(content: String): Parsed = runCatching {
fence.findAll(content).firstNotNullOfOrNull { match ->
val obj = runCatching { json.parseToJsonElement(match.groupValues[1].trim()).jsonObject }.getOrNull()
val array = obj?.get("ideas")?.jsonArray ?: return@firstNotNullOfOrNull null
val ideas = array.mapNotNull { element ->
when (element) {
is JsonObject -> (element["text"] ?: element["idea"])?.jsonPrimitive?.contentOrNull
is JsonPrimitive -> element.contentOrNull
else -> null
}?.trim()?.ifBlank { null }
}
ideas.takeIf { it.isNotEmpty() }?.let { Parsed(content.removeRange(match.range).trim(), it) }
} ?: Parsed(cleanText = content, ideas = emptyList())
}.getOrElse { Parsed(cleanText = content, ideas = emptyList()) }
}
@@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Tokenizer
import com.correx.core.router.model.Idea
import com.correx.core.router.model.NarrationTrigger
import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterL2Entry
@@ -44,6 +45,10 @@ interface RouterContextBuilder {
class DefaultRouterContextBuilder(
private val config: RouterConfig,
private val tokenizer: Tokenizer? = null,
// Cross-session ideas to surface to the router (router-only context). Read fresh each build so
// newly-captured ideas appear on the next turn; injected via the constructor so the build()
// interface — and its many test doubles — stay unchanged.
private val ideaProvider: () -> List<Idea> = { emptyList() },
) : RouterContextBuilder {
companion object {
@@ -73,6 +78,15 @@ class DefaultRouterContextBuilder(
point at the relevant conventions or commands;
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
(d) otherwise offer to think the problem through together in chat.
Rubber-duck capture: while thinking a problem through in chat, when a concrete idea or
decision crystallizes, capture it by ending your reply with a fenced json block:
```json
{"ideas":["<one idea per item, in the operator's framing>"]}
```
These are saved to the operator's idea board (also shown back to you under "## Ideas").
Capture only genuinely new, concrete ideas — omit the block when nothing crystallized,
and never re-capture an idea already under "## Ideas".
""".trimIndent()
private val NARRATION_SYSTEM_PROMPT =
@@ -86,6 +100,9 @@ class DefaultRouterContextBuilder(
""".trimIndent()
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
private const val MAX_IDEAS_IN_CONTEXT = 10
}
override suspend fun build(
@@ -131,10 +148,22 @@ class DefaultRouterContextBuilder(
role = EntryRole.SYSTEM,
)
}
val ideasEntry: ContextEntry? = ideaProvider()
.takeIf { it.isNotEmpty() }
?.let {
buildContextEntry(
sourceType = "ideas",
sourceId = "operator-ideas",
content = buildIdeasContent(it),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
}
// Compute remaining budget after protected frame.
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate +
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0)
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0) +
(ideasEntry?.tokenEstimate ?: 0)
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>()
@@ -142,6 +171,7 @@ class DefaultRouterContextBuilder(
allEntries += workflowStatusEntry
availableWorkflowsEntry?.let { allEntries += it }
projectProfileEntry?.let { allEntries += it }
ideasEntry?.let { allEntries += it }
var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>()
@@ -316,6 +346,14 @@ class DefaultRouterContextBuilder(
}
}.trimEnd()
private fun buildIdeasContent(ideas: List<Idea>): String =
buildString {
append("## Ideas\n")
append("Ideas the operator captured while thinking out loud. Reference them when relevant; ")
append("they are notes, not instructions.\n")
ideas.take(MAX_IDEAS_IN_CONTEXT).forEach { append("- ${it.text}\n") }
}.trimEnd()
private suspend fun buildContextEntry(
sourceType: String,
sourceId: String,
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ChatTurnRole
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent
@@ -156,12 +157,14 @@ class DefaultRouterFacade(
)
val inferenceResponse = provider.infer(inferenceRequest)
val rawContent = inferenceResponse.text
// The triage path may append a structured workflow proposal; strip it from the visible turn
// so the operator reads prose, then record the candidates as a WorkflowProposedEvent below.
// The triage path may append a structured workflow proposal, and the rubber-duck path an
// `ideas` block; strip both from the visible turn so the operator reads only prose, then
// record them as events below.
val proposal = WorkflowProposals.parse(rawContent)
val ideas = Ideas.parse(proposal.cleanText)
// A length-finish with no visible text means the model burned the whole completion
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
val content = proposal.cleanText.ifBlank {
val content = ideas.cleanText.ifBlank {
if (inferenceResponse.finishReason is FinishReason.Length) {
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
"without producing an answer (likely spent on hidden reasoning). " +
@@ -180,6 +183,7 @@ class DefaultRouterFacade(
if (mode == ChatMode.CHAT) {
emitWorkflowProposalIfAny(sessionId, input, proposal)
emitIdeasCaptured(sessionId, ideas.ideas)
}
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
@@ -329,6 +333,31 @@ class DefaultRouterFacade(
)
}
/** Records one [IdeaCapturedEvent] per idea the operator surfaced while rubber-ducking. */
private suspend fun emitIdeasCaptured(sessionId: SessionId, ideas: List<String>) {
ideas.forEach { text ->
val now = Clock.System.now()
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = now,
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = IdeaCapturedEvent(
ideaId = UUID.randomUUID().toString(),
sessionId = sessionId,
text = text,
timestampMs = now.toEpochMilliseconds(),
),
),
)
}
}
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
val now = Clock.System.now()
eventStore.append(
@@ -0,0 +1,8 @@
package com.correx.core.router.model
/** An active idea on the operator's cross-session scratchpad (a captured, not-yet-discarded note). */
data class Idea(
val id: String,
val text: String,
val capturedAtMs: Long,
)
@@ -0,0 +1,55 @@
package com.correx.core.router
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class IdeasTest {
@Test
fun `parses a fenced ideas block and strips it from the prose`() {
val content = """
Good point — two things worth remembering.
```json
{"ideas":["cache the repo map across sessions","add a --dry-run flag"]}
```
""".trimIndent()
val parsed = Ideas.parse(content)
assertEquals(listOf("cache the repo map across sessions", "add a --dry-run flag"), parsed.ideas)
assertTrue(parsed.cleanText.startsWith("Good point"))
assertTrue("```" !in parsed.cleanText, "fence must be stripped from the visible turn")
}
@Test
fun `accepts object items with a text field`() {
val content = "prose\n```json\n{\"ideas\":[{\"text\":\"idea one\"},{\"idea\":\"idea two\"}]}\n```"
val parsed = Ideas.parse(content)
assertEquals(listOf("idea one", "idea two"), parsed.ideas)
}
@Test
fun `finds the ideas block even when it is not the first fence`() {
val content = "```json\n{\"propose_workflows\":[{\"id\":\"x\"}]}\n```\n" +
"and\n```json\n{\"ideas\":[\"keep this\"]}\n```"
val parsed = Ideas.parse(content)
assertEquals(listOf("keep this"), parsed.ideas)
}
@Test
fun `no block yields no ideas and untouched text`() {
val content = "Let's just talk it through."
val parsed = Ideas.parse(content)
assertTrue(parsed.ideas.isEmpty())
assertEquals(content, parsed.cleanText)
}
@Test
fun `blank and empty items are dropped`() {
val content = "x\n```json\n{\"ideas\":[\" \",\"real\",\"\"]}\n```"
val parsed = Ideas.parse(content)
assertEquals(listOf("real"), parsed.ideas)
}
}
@@ -24,6 +24,7 @@ import com.correx.core.inference.NoopEmbedder
import com.correx.core.inference.Tokenizer
import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.IdeaReader
import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.DefaultRouterRepository
import com.correx.core.router.RouterFacade
@@ -300,7 +301,8 @@ object InfrastructureModule {
val projector = RouterProjector(reducer)
val replayer = DefaultEventReplayer(eventStore, projector)
val repository = DefaultRouterRepository(replayer)
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer)
val ideaReader = IdeaReader(eventStore)
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() })
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
}
}
@@ -33,6 +33,7 @@ import com.correx.core.router.ChatMode
import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.IdeaReader
import com.correx.core.router.RouterContextBuilder
import com.correx.core.router.RouterFacade
import com.correx.core.router.RouterProjector
@@ -664,6 +665,50 @@ class RouterFacadeTest {
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
}
@Test
fun `rubber-duck ideas are captured, readable cross-session, and removed by a discard`(): Unit = runBlocking {
val store = mockEventStore()
val reply = "Right — worth keeping.\n```json\n{\"ideas\":[\"cache the repo map\",\"add --dry-run\"]}\n```"
val facade = facadeProposing(store, reply, emptyList())
facade.onUserInput(SessionId("chat-1"), "how do we speed this up?")
val reader = IdeaReader(store)
val ideas = reader.activeIdeas()
assertEquals(2, ideas.size)
assertTrue(ideas.any { it.text == "cache the repo map" } && ideas.any { it.text == "add --dry-run" })
// The visible chat turn must not leak the json block.
val routerTurn = store.appendedEvents.map { it.payload }
.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.ROUTER }
assertFalse(routerTurn.content.contains("```"))
// Discard one (from a *different* session) → it drops off the board, the other stays.
val drop = ideas.first { it.text == "add --dry-run" }
store.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId("disc-1"), sessionId = SessionId("chat-2"),
timestamp = Instant.parse("2026-01-01T00:00:00Z"), schemaVersion = 1, causationId = null, correlationId = null,
),
payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")),
),
)
val after = reader.activeIdeas()
assertEquals(listOf("cache the repo map"), after.map { it.text })
}
@Test
fun `a steering turn never captures ideas`(): Unit = runBlocking {
val store = mockEventStore()
val reply = "x\n```json\n{\"ideas\":[\"nope\"]}\n```"
val facade = facadeProposing(store, reply, emptyList())
facade.onUserInput(SessionId("s"), "steer", ChatMode.STEERING)
assertTrue(IdeaReader(store).activeIdeas().isEmpty())
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------