diff --git a/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt new file mode 100644 index 00000000..f9212085 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt @@ -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 { + val discarded = mutableSetOf() + val captured = mutableListOf() + 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 +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/Ideas.kt b/core/router/src/main/kotlin/com/correx/core/router/Ideas.kt new file mode 100644 index 00000000..fda44e0a --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/Ideas.kt @@ -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) + + 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()) } +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt index 1311c663..e35fef3e 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt @@ -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 = { 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":[""]} + ``` + 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() @@ -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() @@ -316,6 +346,14 @@ class DefaultRouterContextBuilder( } }.trimEnd() + private fun buildIdeasContent(ideas: List): 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, diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt index 3a8f512c..d303647a 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt @@ -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) { + 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( diff --git a/core/router/src/main/kotlin/com/correx/core/router/model/Idea.kt b/core/router/src/main/kotlin/com/correx/core/router/model/Idea.kt new file mode 100644 index 00000000..8f2318e5 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/model/Idea.kt @@ -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, +) diff --git a/core/router/src/test/kotlin/com/correx/core/router/IdeasTest.kt b/core/router/src/test/kotlin/com/correx/core/router/IdeasTest.kt new file mode 100644 index 00000000..12f76ad6 --- /dev/null +++ b/core/router/src/test/kotlin/com/correx/core/router/IdeasTest.kt @@ -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) + } +} diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index de037984..475965d6 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -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) } } diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index 026a8850..89a038a8 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -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().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().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 // --------------------------------------------------------------------------