refactor: rename the router subsystem to Talkie

The 'router' name was misleading — it's the always-on conversational front-end
(CHAT triage + STEERING into a running workflow), not a routing layer, and it's
distinct from InferenceRouter. Rename core:router -> core:talkie, package
com.correx.core.router -> com.correx.core.talkie, and RouterFacade/Config/State/
Repository/ContextBuilder/Projector/Reducer/Response -> Talkie*. Config section
[router] -> [talkie] (legacy [router] still read as fallback).

Persisted wire formats are preserved: ChatTurnRole.ROUTER and the
RouterNarrationEvent @SerialName("RouterNarration") stay, so existing event
logs still replay. RouterNarrationEvent the class is now TalkieNarrationEvent.
This commit is contained in:
2026-07-03 13:26:00 +04:00
parent 77c28ba313
commit 595ec187bc
84 changed files with 736 additions and 734 deletions
+43
View File
@@ -0,0 +1,43 @@
# core/router — AGENTS.md
## Purpose
Router facade and context assembly: builds the context pack delivered to each inference call, manages L3 cross-session memory retrieval, captures ideas from rubber-ducking sessions, and exposes `RouterFacade` as the primary entry point for the routing pipeline.
## Ownership
CORREX kernel team.
## Local Contracts
- `RouterFacade` — primary entry point; orchestrates `RouterContextBuilder`, L3 retrieval, and narration trigger evaluation.
- `RouterContextBuilder` — assembles `ContextPack` from active session state, L3 memory hits, repo knowledge, ideas, and steering entries.
- `RouterState` rebuilt from `RouterEvents` via `RouterReducer` + `RouterProjector`.
- `RouterRepository` wraps `EventReplayer<RouterState>`.
- `RouterResponse` — output from `RouterFacade`: rendered context + routing metadata.
- `RouterConfig` — per-session router configuration (narration mode, L3 enabled, etc.).
- `L3MemoryStore` / `RehydratableL3MemoryStore` / `InMemoryL3MemoryStore` — L3 cross-session memory interfaces. `RehydratableL3MemoryStore` uses `L3MetadataRehydrator` to restore from recorded events (Hard Invariant #9: L3 queries are recorded as events; replay reads the recorded hits, not the live index).
- `L3Hit` / `L3MemoryEntry` / `L3Query` — L3 retrieval types.
- `IdeaReader` / `Idea` / `Ideas` — reads `IdeaCaptured`/`IdeaDiscarded` events to surface idea state as a context feed.
- `WorkflowProposals` / `WorkflowSummary` — proposal types for the workflow-propose panel.
- `NarrationTrigger` — evaluates whether narration should be emitted on this turn.
- Hard Invariant #9: L3 retrieval queries are recorded as events at query time. During replay, `L3MetadataRehydrator` restores the recorded hits — the live ANN index is never queried.
## Work Guidance
- Follow the standard Events→State→Reducer→Projector→Repository pattern (see `core/AGENTS.md`).
- `RouterReducer` only does `state.copy(...)`.
- L3 memory is non-deterministic at query time (ANN search). Always record the hits as events immediately and read those recorded events in all downstream/replay paths.
- `InMemoryL3MemoryStore` is for tests only; production wiring uses `RehydratableL3MemoryStore`.
## Verification
```bash
./gradlew :core:router:test --rerun-tasks
```
Tests in `testing/deterministic/` (RouterContextBuilderTest, RouterFacadeTest, RouterNarrationTest, RouterProjectorTest, L3MetadataRehydratorTest).
## Child DOX Index
No child AGENTS.md (leaf module).
+18
View File
@@ -0,0 +1,18 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id "org.jetbrains.kotlinx.kover"
}
dependencies {
implementation project(':core:events')
implementation project(':core:context')
implementation project(':core:inference')
implementation project(':core:sessions')
implementation "org.slf4j:slf4j-api:2.0.16"
}
tasks.named("koverVerify").configure {
enabled = false
}
@@ -0,0 +1,46 @@
package com.correx.core.talkie
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.talkie.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] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture
* stays in the log).
*/
class IdeaReader(private val eventStore: EventStore) {
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */
fun activeIdeas(): List<Idea> {
val tombstoned = mutableSetOf<String>()
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
else -> Unit
}
}
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
}
/** The session that captured [ideaId], so a tombstone lands alongside its capture. */
fun sessionOf(ideaId: String): SessionId? =
capturedOf(ideaId)?.sessionId
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
fun textOf(ideaId: String): String? =
capturedOf(ideaId)?.text
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
}
@@ -0,0 +1,39 @@
package com.correx.core.talkie
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()) }
}
@@ -0,0 +1,489 @@
package com.correx.core.talkie
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
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.talkie.model.Idea
import com.correx.core.talkie.model.NarrationTrigger
import com.correx.core.talkie.model.TalkieConfig
import com.correx.core.talkie.model.RouterL2Entry
import com.correx.core.talkie.model.TalkieState
import com.correx.core.talkie.model.TalkieTurn
import com.correx.core.talkie.model.TurnRole
import com.correx.core.talkie.model.WorkflowSummary
import java.util.*
import kotlinx.coroutines.CancellationException
interface TalkieContextBuilder {
suspend fun build(
state: TalkieState,
budget: TokenBudget,
availableWorkflows: List<WorkflowSummary> = emptyList(),
projectProfileText: String? = null,
): ContextPack
/**
* Builds a minimal [ContextPack] for a narration inference call.
* Implementations should include system prompt, workflow status, recent L2, and the trigger
* instruction, but must NOT include conversation history or L3 recalled memory.
*
* Default delegates to [build] so that anonymous test stubs that only override [build] compile
* without needing a stub override.
*/
suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack =
build(state, budget, emptyList(), null)
}
class DefaultTalkieContextBuilder(
private val config: TalkieConfig,
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() },
) : TalkieContextBuilder {
companion object {
private val SYSTEM_PROMPT =
"""
You are correx's router — the conversational interface between the operator and a
local, event-sourced workflow engine. You are not the engine: stages, tools, and
approvals are run by the kernel and recorded as events. Your job is to help the
operator understand and steer what the engine is doing.
Ground every statement in the workflow state and conversation context you are given.
Never invent stages, tool results, file contents, or status you have not been shown.
Be concise and direct — plain sentences, no filler. When the operator is steering
an approval, help them state clearly what they want changed.
When the operator's request is not something you can answer or steer directly:
do not simply refuse. Triage instead, citing only what appears in your context:
(a) if "## Available workflows" is present, suggest the best-fitting workflow(s) and
say in prose what each would accomplish for this request. Then, on a final line,
append a fenced json block offering them as a picker:
```json
{"propose_workflows":[{"id":"<workflow-id>","reason":"<one line>"}],"prompt":"<short question>"}
```
Use ONLY ids from "## Available workflows"; list them best-first. The operator
gets a choice panel (with a manual-answer slot) — omit the block entirely if none fit;
(b) if "## Project profile" is present and the request relates to the project,
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 =
"""
You are correx's router, narrating a running workflow to the operator in their live
feed. In one or two short sentences, in your own voice, say what just happened and
what comes next. Be concrete and grounded in the workflow state you are given — name
the stage, the outcome, and the reason for any failure or pause. Do not use lists,
headings, or preamble, and do not invent details you were not given. Speak in the
present, as events unfold.
""".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(
state: TalkieState,
budget: TokenBudget,
availableWorkflows: List<WorkflowSummary>,
projectProfileText: String?,
): ContextPack {
// Protected frame: system prompt and workflow status are ALWAYS included regardless of budget.
val systemPrompt = buildContextEntry(
sourceType = "systemPrompt",
sourceId = "router-system",
content = SYSTEM_PROMPT,
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val workflowStatusEntry = buildContextEntry(
sourceType = "workflowStatus",
sourceId = state.currentStageId?.value ?: "none",
content = buildWorkflowStatusContent(state),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val availableWorkflowsEntry: ContextEntry? = availableWorkflows
.takeIf { it.isNotEmpty() }
?.let {
buildContextEntry(
sourceType = "availableWorkflows",
sourceId = "available-workflows",
content = buildAvailableWorkflowsContent(it),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
}
val projectProfileEntry: ContextEntry? = projectProfileText
?.takeIf { it.isNotBlank() }
?.let {
buildContextEntry(
sourceType = "projectProfile",
sourceId = "project-profile",
content = it,
layer = ContextLayer.L0,
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) +
(ideasEntry?.tokenEstimate ?: 0)
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>()
allEntries += systemPrompt
allEntries += workflowStatusEntry
availableWorkflowsEntry?.let { allEntries += it }
projectProfileEntry?.let { allEntries += it }
ideasEntry?.let { allEntries += it }
var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>()
// --- Conversation turns (L1) ---
// The current (protected) user turn is the last element in conversationHistory,
// regardless of conversationKeepLast — it must always be present.
val protectedUserTurn: TalkieTurn? = state.conversationHistory.lastOrNull()
// Apply conversationKeepLast cap. The protected turn may or may not fall within this window.
val cappedTurns = state.conversationHistory.takeLast(config.conversationKeepLast)
// Build entries for the capped window, excluding the protected turn (to avoid double-counting).
val nonProtectedCapped = if (protectedUserTurn != null && cappedTurns.lastOrNull() == protectedUserTurn) {
cappedTurns.dropLast(1)
} else {
cappedTurns
}
// Build the protected user turn entry.
val protectedTurnEntry: ContextEntry? = protectedUserTurn?.let { turn ->
val role = when (turn.role) {
TurnRole.USER -> EntryRole.USER
TurnRole.ROUTER -> EntryRole.ASSISTANT
}
buildContextEntry(
sourceType = "conversation",
sourceId = "${turn.role.name}-${turn.hashCode()}",
content = turn.content,
layer = ContextLayer.L1,
role = role,
)
}
// Reserve budget for the protected user turn first.
if (protectedTurnEntry != null) {
remainingBudget = (remainingBudget - protectedTurnEntry.tokenEstimate).coerceAtLeast(0)
}
// Fit remaining capped turns newest-to-oldest (excluding protected), so oldest get dropped when tight.
val nonProtectedTurnEntries = nonProtectedCapped.map { turn ->
val role = when (turn.role) {
TurnRole.USER -> EntryRole.USER
TurnRole.ROUTER -> EntryRole.ASSISTANT
}
buildContextEntry(
sourceType = "conversation",
sourceId = "${turn.role.name}-${turn.hashCode()}",
content = turn.content,
layer = ContextLayer.L1,
role = role,
)
}
val fittedTurns = mutableListOf<ContextEntry>()
for (entry in nonProtectedTurnEntries.asReversed()) {
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
fittedTurns.add(entry)
} else {
droppedCount++
truncatedLayers.add(ContextLayer.L1)
}
}
// Re-order fitted turns oldest-to-newest (reverse back) and append protected turn at end.
fittedTurns.reverse()
allEntries.addAll(fittedTurns)
protectedTurnEntry?.let { allEntries.add(it) }
// --- L3 memory (recalled cross-session) ---
// Sort by score descending (highest relevance first), add what fits.
val sortedHits = state.lastRetrievedMemory.sortedByDescending { it.score }
for (hit in sortedHits) {
val content = "$RECALLED_MEMORY_PREFIX ${hit.text}"
val entry = buildContextEntry(
sourceType = "recalledMemory",
sourceId = hit.entryId,
content = content,
layer = ContextLayer.L3,
role = EntryRole.SYSTEM,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
allEntries += entry
} else {
droppedCount++
truncatedLayers.add(ContextLayer.L3)
}
}
// --- L2 memory (compressed session summaries) ---
// Iterate newest-to-oldest so that when budget is tight the OLDEST entries are dropped.
val fittedL2 = mutableListOf<ContextEntry>()
for (l2Entry in state.l2Memory.asReversed()) {
val content = buildL2Content(l2Entry)
val entry = buildContextEntry(
sourceType = "stageSummary",
sourceId = l2Entry.stageId.value,
content = content,
layer = ContextLayer.L2,
role = EntryRole.SYSTEM,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
fittedL2.add(entry)
} else {
droppedCount++
truncatedLayers.add(ContextLayer.L2)
}
}
// Restore chronological (oldest-to-newest) order.
fittedL2.reverse()
allEntries.addAll(fittedL2)
// Build layers map sorted by ContextLayer ordinal for deterministic ordering.
val layers = allEntries
.groupBy { it.layer }
.toSortedMap(compareBy { it.ordinal })
val budgetUsed = allEntries.sumOf { it.tokenEstimate }
// Build appliedStrategies list — deterministic, stable order.
val appliedStrategies = buildList {
add("L0Immutable")
add("Conversation")
if (state.lastRetrievedMemory.isNotEmpty()) add("L3Recall")
if (budgetUsed > budget.limit) add("BudgetExceeded")
}
return ContextPack(
id = ContextPackId("${state.sessionId?.value ?: "unknown"}-router-pack"),
sessionId = state.sessionId ?: SessionId("unknown"),
stageId = state.currentStageId ?: StageId.NONE,
layers = layers,
budgetUsed = budgetUsed,
budgetLimit = budget.limit,
compressionMetadata = CompressionMetadata(
appliedStrategies = appliedStrategies,
truncatedLayers = truncatedLayers.toList(),
entriesDropped = droppedCount,
),
)
}
private fun buildWorkflowStatusContent(state: TalkieState, stageOverride: String? = null): String {
val status = capitalizeFirst(state.workflowStatus.name.lowercase())
val stage = stageOverride ?: state.currentStageId?.value ?: "none"
return "Status: $status, Stage: $stage"
}
private fun capitalizeFirst(s: String): String {
return s.lowercase().let { it[0].uppercaseChar() + it.drop(1) }
}
private fun buildL2Content(l2Entry: RouterL2Entry): String {
val base = "Stage ${l2Entry.stageId.value} (${l2Entry.outcome}): ${l2Entry.summary}"
val reason = l2Entry.reason?.let { " — reason: $it" }.orEmpty()
val steering = if (l2Entry.steeringNotes.isNotEmpty()) {
" | steering: " + l2Entry.steeringNotes.joinToString("; ")
} else {
""
}
return base + reason + steering
}
private fun buildAvailableWorkflowsContent(workflows: List<WorkflowSummary>): String =
buildString {
append("## Available workflows\n")
workflows.forEach { wf ->
append("- ${wf.id}: ${wf.description} (stages: ${wf.stageIds.joinToString(" → ")})\n")
}
}.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,
content: String,
layer: ContextLayer = ContextLayer.L0,
role: EntryRole = EntryRole.USER,
): ContextEntry {
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = layer,
content = content,
sourceType = sourceType,
sourceId = sourceId,
tokenEstimate = estimateTokens(content),
role = role,
)
}
private suspend fun estimateTokens(content: String): Int {
val t = tokenizer
if (t != null) {
return runCatching { t.countTokens(content) }.getOrElse { e ->
if (e is CancellationException) throw e
fallbackEstimate(content)
}
}
return fallbackEstimate(content)
}
private fun fallbackEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1)
}
/**
* Builds a minimal [ContextPack] for a single narration inference call.
*
* Contains (in order, subject to [budget]):
* 1. L0 — system prompt (always present)
* 2. L0 — workflow status (always present)
* 3. L2 — recent stage summaries (oldest dropped first when tight)
* 4. L0 — trigger instruction (always present as the final user-facing entry)
*
* Conversation history and L3 recalled memory are intentionally excluded so
* narration lines never enter the conversation flow.
*/
override suspend fun buildNarrationContext(
state: TalkieState,
trigger: NarrationTrigger,
budget: TokenBudget,
): ContextPack {
val systemPromptEntry = buildContextEntry(
sourceType = "systemPrompt",
sourceId = "router-narration-system",
content = NARRATION_SYSTEM_PROMPT,
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
val workflowStatusEntry = buildContextEntry(
sourceType = "workflowStatus",
sourceId = trigger.stageId ?: state.currentStageId?.value ?: "none",
content = buildWorkflowStatusContent(state, trigger.stageId),
layer = ContextLayer.L0,
role = EntryRole.SYSTEM,
)
// L1, not L0: PromptRenderer folds every L0 entry into the single system message
// regardless of role, so an L0 user turn would vanish into the system block and the
// request would go out with no user message (the chat template then rejects it).
val triggerEntry = buildContextEntry(
sourceType = "narrationTrigger",
sourceId = trigger.kind,
content = trigger.instruction,
layer = ContextLayer.L1,
role = EntryRole.USER,
)
val protectedTokens = systemPromptEntry.tokenEstimate +
workflowStatusEntry.tokenEstimate +
triggerEntry.tokenEstimate
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
val allEntries = mutableListOf<ContextEntry>()
allEntries += systemPromptEntry
allEntries += workflowStatusEntry
var droppedCount = 0
val truncatedLayers = mutableSetOf<ContextLayer>()
// L2 stage summaries — newest-to-oldest so oldest get dropped first when tight.
val fittedL2 = mutableListOf<ContextEntry>()
for (l2Entry in state.l2Memory.asReversed()) {
val content = buildL2Content(l2Entry)
val entry = buildContextEntry(
sourceType = "stageSummary",
sourceId = l2Entry.stageId.value,
content = content,
layer = ContextLayer.L2,
role = EntryRole.SYSTEM,
)
if (remainingBudget >= entry.tokenEstimate) {
remainingBudget -= entry.tokenEstimate
fittedL2.add(entry)
} else {
droppedCount++
truncatedLayers.add(ContextLayer.L2)
}
}
fittedL2.reverse()
allEntries.addAll(fittedL2)
// Trigger instruction is always last.
allEntries += triggerEntry
val layers = allEntries
.groupBy { it.layer }
.toSortedMap(compareBy { it.ordinal })
val budgetUsed = allEntries.sumOf { it.tokenEstimate }
return ContextPack(
id = ContextPackId("${state.sessionId?.value ?: "unknown"}-narration-pack"),
sessionId = state.sessionId ?: SessionId("unknown"),
stageId = state.currentStageId ?: StageId.NONE,
layers = layers,
budgetUsed = budgetUsed,
budgetLimit = budget.limit,
compressionMetadata = CompressionMetadata(
appliedStrategies = listOf("L0Immutable", "NarrationContext"),
truncatedLayers = truncatedLayers.toList(),
entriesDropped = droppedCount,
),
)
}
}
@@ -0,0 +1,391 @@
package com.correx.core.talkie
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
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.TokenUsage
import com.correx.core.talkie.l3.L3MemoryEntry
import com.correx.core.talkie.l3.L3MemoryStore
import com.correx.core.talkie.l3.L3Query
import com.correx.core.talkie.model.NarrationTrigger
import com.correx.core.talkie.model.TalkieConfig
import com.correx.core.talkie.model.TalkieResponse
import com.correx.core.talkie.model.WorkflowSummary
import kotlinx.coroutines.CancellationException
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID
private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java)
interface TalkieFacade {
suspend fun onUserInput(
sessionId: SessionId,
input: String,
mode: ChatMode = ChatMode.CHAT,
): TalkieResponse
suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)
}
class DefaultTalkieFacade(
private val routerRepository: TalkieRepository,
private val routerContextBuilder: TalkieContextBuilder,
private val inferenceRouter: InferenceRouter,
private val eventStore: EventStore,
private val config: TalkieConfig,
private val validateSteering: (suspend (String) -> String?)? = null,
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
private val workflowSummaryProvider: () -> List<WorkflowSummary> = { emptyList() },
private val sessionProfileProvider: suspend (SessionId) -> String? = { null },
) : TalkieFacade {
override suspend fun onUserInput(
sessionId: SessionId,
input: String,
mode: ChatMode,
): TalkieResponse {
// Emit USER turn event and get the turnId for retrieval dedup
val userTurnId = emitChatTurn(sessionId, input, ChatTurnRole.USER)
// Rebuild state with user turn appended
var state = routerRepository.getTalkieState(sessionId)
val effectiveStageId = state.currentStageId ?: StageId.NONE
// L3 retrieval — non-fatal; results fed back into state via event.
// Always emit L3MemoryRetrievedEvent for every CHAT turn where retrieval was attempted,
// including with empty hits — so lastRetrievedMemory is always current-turn-scoped.
val retrieved = runCatching {
val queryVector = embedder.embed(input)
l3MemoryStore.query(L3Query(vector = queryVector, k = config.retrievalK))
}.also { result ->
result.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("L3 retrieval failed for session {}: {}", sessionId, e.message)
}
}.getOrElse { emptyList() }
val inSessionTurnIds = state.conversationHistory.map { it.turnId }.toSet()
val deduped = retrieved.filter { it.entry.turnId !in inSessionTurnIds }
// Always emit L3MemoryRetrievedEvent (even with empty hits) so that
// lastRetrievedMemory always reflects this turn and a prior turn's hits
// are never injected into the next turn's context.
val retrievalNow = Clock.System.now()
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = retrievalNow,
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = L3MemoryRetrievedEvent(
sessionId = sessionId,
queryTurnId = userTurnId,
hits = deduped.map { hit ->
L3RetrievedHit(
entryId = hit.entry.id,
sourceSessionId = hit.entry.sessionId,
sourceTurnId = hit.entry.turnId,
text = hit.entry.text,
score = hit.score,
)
},
timestampMs = retrievalNow.toEpochMilliseconds(),
),
),
)
// Rebuild state so lastRetrievedMemory reflects this turn
state = routerRepository.getTalkieState(sessionId)
val contextPack = routerContextBuilder.build(state, config.tokenBudget, workflowSummaryProvider(), sessionProfileProvider(sessionId))
if (contextPack.compressionMetadata.entriesDropped > 0) {
val truncNow = Clock.System.now()
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = truncNow,
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ContextTruncatedEvent(
sessionId = sessionId,
turnId = userTurnId,
entriesDropped = contextPack.compressionMetadata.entriesDropped,
truncatedLayers = contextPack.compressionMetadata.truncatedLayers.map { it.name },
timestampMs = truncNow.toEpochMilliseconds(),
),
),
)
}
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = effectiveStageId,
contextPack = contextPack,
generationConfig = config.generationConfig,
responseFormat = ResponseFormat.Text,
)
val inferenceResponse = provider.infer(inferenceRequest)
val rawContent = inferenceResponse.text
// 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 = 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). " +
"Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
} else {
"The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
}
}
// Emit ROUTER turn event
emitChatTurn(
sessionId, content, ChatTurnRole.ROUTER,
latencyMs = inferenceResponse.latencyMs,
tokensUsed = inferenceResponse.tokensUsed,
)
if (mode == ChatMode.CHAT) {
emitWorkflowProposalIfAny(sessionId, input, proposal)
emitIdeasCaptured(sessionId, ideas.ideas)
}
// ponytail: STEERING launders the user's text through the router LLM (the `content` above)
// before injecting it as a note. For a clear instruction that's an extra inference that can
// distort intent; the reformulation only earns its cost when the input is terse/context-
// dependent. Upgrade path: inject the raw (validated) input directly and skip the rewrite
// unless a heuristic flags the message as too short/ambiguous to stand alone.
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
if (steeringEmitted) {
val validationError = validateSteering?.invoke(content)
if (validationError == null) {
emitSteeringNote(sessionId, content, effectiveStageId)
}
}
return TalkieResponse(content = content, steeringEmitted = steeringEmitted)
}
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
val state = routerRepository.getTalkieState(sessionId)
val effectiveStageId = trigger.stageId?.let { StageId(it) } ?: state.currentStageId ?: StageId.NONE
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget)
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId)
val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = effectiveStageId,
contextPack = contextPack,
generationConfig = config.narrationGenerationConfig,
responseFormat = ResponseFormat.Text,
)
val inferenceResponse = provider.infer(inferenceRequest)
val content = inferenceResponse.text
if (content.isBlank()) return
val narrationId = UUID.randomUUID().toString()
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 = TalkieNarrationEvent(
sessionId = sessionId,
narrationId = narrationId,
trigger = trigger.kind,
stageId = effectiveStageId.takeIf { it != StageId.NONE }?.value,
content = content,
latencyMs = inferenceResponse.latencyMs,
tokensUsed = inferenceResponse.tokensUsed,
timestampMs = now.toEpochMilliseconds(),
),
),
)
}
private suspend fun emitChatTurn(
sessionId: SessionId,
content: String,
role: ChatTurnRole,
latencyMs: Long? = null,
tokensUsed: TokenUsage? = null,
): String {
val turnId = UUID.randomUUID().toString()
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 = ChatTurnEvent(
sessionId = sessionId,
turnId = turnId,
role = role,
content = content,
timestampMs = now.toEpochMilliseconds(),
latencyMs = latencyMs,
tokensUsed = tokensUsed,
),
),
)
runCatching {
val vector = embedder.embed(content)
l3MemoryStore.store(
L3MemoryEntry(
id = turnId,
sessionId = sessionId,
turnId = turnId,
text = content,
vector = vector,
timestampMs = now.toEpochMilliseconds(),
)
)
}.also { result ->
result.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("L3 write failed for turn {}: {}", turnId, e.message)
}
}
return turnId
}
/**
* Records a [WorkflowProposedEvent] when the triage reply proposed at least one *known* workflow.
* Candidate ids are validated against the registered workflows (invariant #7 — LLM output is
* untrusted), so an invented id never reaches the operator's picker.
*/
private suspend fun emitWorkflowProposalIfAny(
sessionId: SessionId,
originalRequest: String,
proposal: WorkflowProposals.Parsed,
) {
if (proposal.candidates.isEmpty()) return
val known = workflowSummaryProvider().mapTo(mutableSetOf()) { it.id }
val valid = proposal.candidates.filter { it.workflowId in known }
if (valid.isEmpty()) return
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 = WorkflowProposedEvent(
sessionId = sessionId,
proposalId = UUID.randomUUID().toString(),
prompt = proposal.prompt.ifBlank { "Start one of these workflows?" },
candidates = valid,
originalRequest = originalRequest,
),
),
)
}
/** 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(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = now,
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SteeringNoteAddedEvent(
sessionId = sessionId,
content = content,
stageId = effectiveStageId.takeIf { it != StageId.NONE },
),
),
)
}
}
enum class ChatMode {
CHAT,
STEERING,
}
@@ -0,0 +1,15 @@
package com.correx.core.talkie
import com.correx.core.events.events.StoredEvent
import com.correx.core.talkie.model.TalkieState
import com.correx.core.sessions.projections.Projection
class TalkieProjector(
private val reducer: TalkieReducer,
) : Projection<TalkieState> {
override fun initial(): TalkieState = reducer.initial
override fun apply(state: TalkieState, event: StoredEvent): TalkieState =
reducer.reduce(state, event)
}
@@ -0,0 +1,142 @@
package com.correx.core.talkie
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.talkie.model.RouterL2Entry
import com.correx.core.talkie.model.TalkieState
import com.correx.core.talkie.model.TalkieTurn
import com.correx.core.talkie.model.StageOutcomeKind
import com.correx.core.talkie.model.TurnRole
import com.correx.core.talkie.model.WorkflowStatus
import kotlinx.datetime.Instant
interface TalkieReducer {
val initial: TalkieState
fun reduce(state: TalkieState, event: StoredEvent): TalkieState
}
class DefaultTalkieReducer : TalkieReducer {
override val initial: TalkieState = TalkieState(
sessionId = null,
workflowStatus = WorkflowStatus.IDLE,
currentStageId = null,
l2Memory = emptyList(),
conversationHistory = emptyList(),
)
override fun reduce(state: TalkieState, event: StoredEvent): TalkieState {
return when (val payload = event.payload) {
is WorkflowStartedEvent -> handleWorkflowStarted(state, event)
is WorkflowCompletedEvent -> handleWorkflowCompleted(state)
is WorkflowFailedEvent -> handleWorkflowFailed(state)
is OrchestrationPausedEvent -> handleOrchestrationPaused(state)
is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
is StageCompletedEvent -> handleStageCompleted(state, event)
is StageFailedEvent -> handleStageFailed(state, event)
is SteeringNoteAddedEvent -> handleSteeringNote(state, payload)
is ChatTurnEvent -> handleChatTurn(state, event)
is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits)
else -> state
}
}
private fun handleWorkflowStarted(state: TalkieState, event: StoredEvent): TalkieState {
val payload = event.payload as WorkflowStartedEvent
return state.copy(
sessionId = payload.sessionId,
workflowStatus = WorkflowStatus.RUNNING,
currentStageId = payload.startStageId
)
}
private fun handleWorkflowCompleted(state: TalkieState): TalkieState {
return state.copy(
workflowStatus = WorkflowStatus.COMPLETED,
currentStageId = null
)
}
private fun handleWorkflowFailed(state: TalkieState): TalkieState {
return state.copy(
workflowStatus = WorkflowStatus.FAILED,
currentStageId = null
)
}
private fun handleOrchestrationPaused(state: TalkieState): TalkieState {
return state.copy(
workflowStatus = WorkflowStatus.PAUSED
)
}
private fun handleOrchestrationResumed(state: TalkieState): TalkieState {
return state.copy(
workflowStatus = WorkflowStatus.RUNNING
)
}
private fun handleStageCompleted(state: TalkieState, event: StoredEvent): TalkieState {
val payload = event.payload as StageCompletedEvent
val entry = RouterL2Entry(
stageId = payload.stageId,
summary = "Stage ${payload.stageId.value} completed",
outcome = StageOutcomeKind.SUCCESS,
timestamp = event.metadata.timestamp,
steeringNotes = state.pendingSteeringNotes,
)
return state.copy(
l2Memory = state.l2Memory + entry,
pendingSteeringNotes = emptyList(),
)
}
private fun handleStageFailed(state: TalkieState, event: StoredEvent): TalkieState {
val payload = event.payload as StageFailedEvent
val entry = RouterL2Entry(
stageId = payload.stageId,
summary = "Stage ${payload.stageId.value} failed",
outcome = StageOutcomeKind.FAILURE,
timestamp = event.metadata.timestamp,
reason = payload.reason,
steeringNotes = state.pendingSteeringNotes,
)
return state.copy(
l2Memory = state.l2Memory + entry,
currentStageId = null,
pendingSteeringNotes = emptyList(),
)
}
// adr-0003 §4: harvest user steering into the current stage's decision point.
// Accumulate here; handleStageCompleted/Failed attach and clear. Pure field
// extraction from the event stream — no inference.
private fun handleSteeringNote(state: TalkieState, payload: SteeringNoteAddedEvent): TalkieState =
state.copy(pendingSteeringNotes = state.pendingSteeringNotes + payload.content)
private fun handleChatTurn(state: TalkieState, event: StoredEvent): TalkieState {
val payload = event.payload as ChatTurnEvent
val turnRole = when (payload.role) {
com.correx.core.events.events.ChatTurnRole.USER -> TurnRole.USER
com.correx.core.events.events.ChatTurnRole.ROUTER -> TurnRole.ROUTER
}
val turn = TalkieTurn(
role = turnRole,
content = payload.content,
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
turnId = payload.turnId,
)
return state.copy(
conversationHistory = state.conversationHistory + turn
)
}
}
@@ -0,0 +1,16 @@
package com.correx.core.talkie
import com.correx.core.events.types.SessionId
import com.correx.core.talkie.model.TalkieState
import com.correx.core.sessions.projections.replay.EventReplayer
interface TalkieRepository {
suspend fun getTalkieState(sessionId: SessionId): TalkieState
}
class DefaultTalkieRepository(
private val replayer: EventReplayer<TalkieState>,
) : TalkieRepository {
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
replayer.rebuild(sessionId)
}
@@ -0,0 +1,46 @@
package com.correx.core.talkie
import com.correx.core.events.events.ProposedWorkflow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Parses an optional `propose_workflows` block out of a router chat reply: the triage path may end
* its prose with a fenced ```` ```json {"propose_workflows":[{"id":..,"reason":..}],"prompt":..} ``` ````
* block. Lenient and fence-tolerant — any parse failure or an absent/empty array yields no proposal.
* Returns the reply with the block stripped (so the operator sees only prose) alongside the parsed
* candidates, so the router records a structured [com.correx.core.events.events.WorkflowProposedEvent]
* while still showing a clean chat turn.
*/
object WorkflowProposals {
data class Parsed(
val cleanText: String,
val prompt: String,
val candidates: List<ProposedWorkflow>,
)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val fence = Regex("```(?:json)?\\s*(.*?)```", RegexOption.DOT_MATCHES_ALL)
fun parse(content: String): Parsed = runCatching {
val match = fence.find(content) ?: return@runCatching none(content)
val obj = json.parseToJsonElement(match.groupValues[1].trim()).jsonObject
val array = obj["propose_workflows"]?.jsonArray ?: return@runCatching none(content)
val candidates = array.mapNotNull { element ->
val item = element as? JsonObject ?: return@mapNotNull null
val id = (item["id"] ?: item["workflowId"])?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
ProposedWorkflow(workflowId = id, reason = item["reason"]?.jsonPrimitive?.contentOrNull ?: "")
}
if (candidates.isEmpty()) return@runCatching none(content)
val prompt = obj["prompt"]?.jsonPrimitive?.contentOrNull ?: ""
val clean = content.removeRange(match.range).trim()
Parsed(cleanText = clean.ifBlank { prompt }, prompt = prompt, candidates = candidates)
}.getOrElse { none(content) }
private fun none(content: String) = Parsed(cleanText = content, prompt = "", candidates = emptyList())
}
@@ -0,0 +1,51 @@
package com.correx.core.talkie.l3
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.sqrt
/**
* In-memory L3 memory store for testing. Not production-grade.
* Uses cosine similarity for vector matching, thread-safe via Mutex.
*/
class InMemoryL3MemoryStore : L3MemoryStore {
private val entries = mutableListOf<L3MemoryEntry>()
private val mutex = Mutex()
override suspend fun store(entry: L3MemoryEntry) {
mutex.withLock {
entries.add(entry)
}
}
override suspend fun query(query: L3Query): List<L3Hit> {
return mutex.withLock {
entries
.map { entry ->
val score = cosineSimilarity(query.vector, entry.vector)
L3Hit(entry, score)
}
.filter { hit ->
query.sessionIdFilter == null || hit.entry.sessionId == query.sessionIdFilter
}
.sortedByDescending { it.score }
.take(query.k)
}
}
override suspend fun existsByTurnIdPrefix(prefix: String): Boolean =
mutex.withLock { entries.any { it.turnId.startsWith(prefix) } }
override suspend fun close() {
// No-op for in-memory store
}
private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
require(a.size == b.size) { "Vector sizes must match" }
val dotProduct = a.indices.sumOf { (a[it] * b[it]).toDouble() }
val normA = sqrt(a.indices.sumOf { (a[it] * a[it]).toDouble() })
val normB = sqrt(b.indices.sumOf { (b[it] * b[it]).toDouble() })
if (normA == 0.0 || normB == 0.0) return 0f
return (dotProduct / (normA * normB)).toFloat()
}
}
@@ -0,0 +1,9 @@
package com.correx.core.talkie.l3
import kotlinx.serialization.Serializable
@Serializable
data class L3Hit(
val entry: L3MemoryEntry,
val score: Float
)
@@ -0,0 +1,45 @@
package com.correx.core.talkie.l3
import com.correx.core.events.types.SessionId
import kotlinx.serialization.Serializable
@Serializable
data class L3MemoryEntry(
val id: String,
val sessionId: SessionId,
val turnId: String,
val text: String,
val vector: FloatArray,
val timestampMs: Long
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as L3MemoryEntry
if (id != other.id) return false
if (sessionId != other.sessionId) return false
if (turnId != other.turnId) return false
if (text != other.text) return false
if (!vector.contentEquals(other.vector)) return false
if (timestampMs != other.timestampMs) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + sessionId.hashCode()
result = 31 * result + turnId.hashCode()
result = 31 * result + text.hashCode()
result = 31 * result + vector.contentHashCode()
result = 31 * result + timestampMs.hashCode()
return result
}
override fun toString(): String {
return "L3MemoryEntry(id='$id', sessionId='$sessionId', turnId='$turnId', " +
"text='$text', vector=${vector.contentToString()}, timestampMs=$timestampMs)"
}
}
@@ -0,0 +1,27 @@
package com.correx.core.talkie.l3
/**
* Cross-session memory store interface for vector-based retrieval.
* Vectors are pre-computed; this contract focuses on storage and similarity search.
*/
interface L3MemoryStore {
/**
* Store a memory entry with its vector.
*/
suspend fun store(entry: L3MemoryEntry)
/**
* Query the store with a vector; returns top-k hits sorted by similarity score descending.
*/
suspend fun query(query: L3Query): List<L3Hit>
/**
* Returns true if any stored entry has a [L3MemoryEntry.turnId] that starts with [prefix].
*/
suspend fun existsByTurnIdPrefix(prefix: String): Boolean
/**
* Close the store and release resources.
*/
suspend fun close()
}
@@ -0,0 +1,40 @@
package com.correx.core.talkie.l3
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.stores.EventStore
import org.slf4j.LoggerFactory
/**
* Rebuilds a [RehydratableL3MemoryStore]'s in-memory metadata map from the
* `ChatTurnEvent` log at startup.
*
* Persisted vectors (e.g. the TurboVec sidecar index) survive a restart, but the
* metadata map does not; without this every L3 query silently drops its hits
* because the `id → metadata` lookup misses. Rehydration reads only the event log
* (no embedder), so it is cheap and replay-safe. Stores without independently
* persisted vectors are not rehydratable and are skipped.
*/
class L3MetadataRehydrator(private val eventStore: EventStore) {
private val log = LoggerFactory.getLogger(L3MetadataRehydrator::class.java)
/** @return the number of entries rehydrated (0 if [store] is not rehydratable). */
suspend fun rehydrate(store: L3MemoryStore): Int {
if (store !is RehydratableL3MemoryStore) return 0
val entries = eventStore.allEvents()
.mapNotNull { it.payload as? ChatTurnEvent }
.map { turn ->
L3MemoryEntry(
id = turn.turnId,
sessionId = turn.sessionId,
turnId = turn.turnId,
text = turn.content,
vector = FloatArray(0),
timestampMs = turn.timestampMs,
)
}
.toList()
store.rehydrateMetadata(entries)
log.info("L3 metadata rehydrated: {} chat turns from event log", entries.size)
return entries.size
}
}
@@ -0,0 +1,35 @@
package com.correx.core.talkie.l3
import com.correx.core.events.types.SessionId
import kotlinx.serialization.Serializable
@Serializable
data class L3Query(
val vector: FloatArray,
val k: Int,
val sessionIdFilter: SessionId? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as L3Query
if (!vector.contentEquals(other.vector)) return false
if (k != other.k) return false
if (sessionIdFilter != other.sessionIdFilter) return false
return true
}
override fun hashCode(): Int {
var result = vector.contentHashCode()
result = 31 * result + k
result = 31 * result + (sessionIdFilter?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "L3Query(vector=${vector.contentToString()}, k=$k, sessionIdFilter=$sessionIdFilter)"
}
}
@@ -0,0 +1,23 @@
package com.correx.core.talkie.l3
/**
* An [L3MemoryStore] whose vectors persist independently of the JVM (e.g. a
* sidecar index on disk) but whose entry metadata (`sessionId`/`turnId`/`text`)
* lives in memory and is therefore lost on restart.
*
* Such a store can rebuild that metadata from the `ChatTurnEvent` log at startup:
* the event log is the source of truth (invariant #1) and the metadata map is a
* disposable projection. Only the metadata map is loaded — the persisted vectors
* are never re-sent, so rehydration needs no embedder.
*
* Stores whose vectors are themselves volatile (e.g. [InMemoryL3MemoryStore])
* deliberately do NOT implement this: rehydrating metadata without matching
* vectors would be meaningless.
*/
interface RehydratableL3MemoryStore : L3MemoryStore {
/**
* Load entry metadata into the in-memory map without re-sending vectors.
* Idempotent: re-running must not clobber entries already present.
*/
suspend fun rehydrateMetadata(entries: List<L3MemoryEntry>)
}
@@ -0,0 +1,8 @@
package com.correx.core.talkie.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,20 @@
package com.correx.core.talkie.model
/**
* Describes the event or condition that prompted a narration inference call.
*
* The trigger is stored verbatim in [TalkieNarrationEvent.trigger] so it can be
* replayed and displayed without re-querying the environment.
*/
data class NarrationTrigger(
/** Short label identifying the trigger kind (e.g. "stage_completed", "tool_executed"). */
val kind: String,
/** Human-readable instruction injected as the final context entry for this narration. */
val instruction: String,
/**
* Stage the triggering event belongs to. Carried on the trigger because the router
* projection's currentStageId is already cleared (workflow terminal events) or not yet
* written when the narrator fires — reading it back from state races the reducer.
*/
val stageId: String? = null,
)
@@ -0,0 +1,28 @@
package com.correx.core.talkie.model
import com.correx.core.context.model.TokenBudget
import com.correx.core.inference.GenerationConfig
import kotlinx.serialization.Serializable
@Serializable
data class TalkieConfig(
val conversationKeepLast: Int = 6,
val retrievalK: Int = 5,
val tokenBudget: TokenBudget = TokenBudget(limit = 4096),
val generationConfig: GenerationConfig = GenerationConfig(
temperature = 0.7,
topP = 0.9,
maxTokens = 512,
),
// Narration uses a larger token ceiling: reasoning models spend tokens "thinking"
// before emitting content, and a tighter cap left no room for the actual line (the
// response came back empty with finishReason=length). 1024 still wasn't enough —
// the model burned the whole budget reasoning and emitted no content — so 4096
// gives reasoning room to complete and still produce the one or two sentences.
val narrationGenerationConfig: GenerationConfig = GenerationConfig(
temperature = 0.7,
topP = 0.9,
maxTokens = 4096,
),
val narrationModelId: String? = null,
)
@@ -0,0 +1,9 @@
package com.correx.core.talkie.model
import kotlinx.serialization.Serializable
@Serializable
data class TalkieResponse(
val content: String,
val steeringEmitted: Boolean = false,
)
@@ -0,0 +1,64 @@
package com.correx.core.talkie.model
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
enum class WorkflowStatus {
IDLE,
RUNNING,
PAUSED,
COMPLETED,
FAILED,
}
@Serializable
enum class StageOutcomeKind {
SUCCESS,
FAILURE,
CANCELLED,
}
@Serializable
enum class TurnRole {
USER,
ROUTER,
}
@Serializable
data class RouterL2Entry(
val stageId: StageId,
val summary: String,
val outcome: StageOutcomeKind,
val timestamp: Instant,
/** Failure reason, when [outcome] is FAILURE. */
val reason: String? = null,
/** User steering notes issued during this stage (adr-0003 §4 decision-point harvest). */
val steeringNotes: List<String> = emptyList(),
)
@Serializable
data class TalkieTurn(
val role: TurnRole,
val content: String,
val timestamp: Instant,
val turnId: String = "",
)
@Serializable
data class TalkieState(
val sessionId: SessionId? = null,
val workflowStatus: WorkflowStatus = WorkflowStatus.IDLE,
val currentStageId: StageId? = null,
val l2Memory: List<RouterL2Entry> = emptyList(),
val conversationHistory: List<TalkieTurn> = emptyList(),
val lastRetrievedMemory: List<L3RetrievedHit> = emptyList(),
/**
* Steering notes accumulated during the current stage, harvested into the
* stage's [RouterL2Entry] when it completes or fails, then cleared.
*/
val pendingSteeringNotes: List<String> = emptyList(),
)
@@ -0,0 +1,7 @@
package com.correx.core.talkie.model
data class WorkflowSummary(
val id: String,
val description: String,
val stageIds: List<String>,
)
@@ -0,0 +1,100 @@
package com.correx.core.talkie
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class IdeaReaderTest {
private val session = SessionId("s-1")
@Test
fun `a promoted idea is filtered out of the board like a discard`() {
val store = fakeStore(
IdeaCapturedEvent("keep", session, "cache the repo map", timestampMs = 1),
IdeaCapturedEvent("promoted", session, "no bare try-catch", timestampMs = 2),
IdeaCapturedEvent("discarded", session, "old note", timestampMs = 3),
IdeaPromotedEvent("promoted", session, "no bare try-catch", timestampMs = 99),
IdeaDiscardedEvent("discarded", session),
)
val active = IdeaReader(store).activeIdeas()
assertEquals(listOf("keep"), active.map { it.id })
assertFalse(active.any { it.id == "promoted" }, "promoted idea must leave the board")
assertFalse(active.any { it.id == "discarded" }, "discarded idea must leave the board")
}
@Test
fun `textOf returns the captured text and null for an unknown idea`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "events are the only source of truth", timestampMs = 1),
)
val reader = IdeaReader(store)
assertEquals("events are the only source of truth", reader.textOf("i1"))
assertNull(reader.textOf("missing"))
}
@Test
fun `textOf still resolves the text after the idea is promoted`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "prefer data classes", timestampMs = 1),
IdeaPromotedEvent("i1", session, "prefer data classes", timestampMs = 5),
)
val reader = IdeaReader(store)
// The capture stays in the log (invariant #1), so the text remains readable.
assertEquals("prefer data classes", reader.textOf("i1"))
assertTrue(reader.activeIdeas().isEmpty())
}
private fun fakeStore(vararg payloads: EventPayload): EventStore = FakeEventStore(payloads.toList())
/** Minimal read-only [EventStore]: only [allEvents] is exercised by [IdeaReader]. */
private class FakeEventStore(payloads: List<EventPayload>) : EventStore {
private val stored: List<StoredEvent> = payloads.mapIndexed { index, payload ->
StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$index"),
sessionId = SessionId("s-1"),
timestamp = Instant.fromEpochMilliseconds(index.toLong()),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = index.toLong(),
sessionSequence = index.toLong(),
payload = payload,
)
}
override fun allEvents(): Sequence<StoredEvent> = stored.asSequence()
override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError()
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = throw NotImplementedError()
override fun read(sessionId: SessionId): List<StoredEvent> = throw NotImplementedError()
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
throw NotImplementedError()
override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError()
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = throw NotImplementedError()
override fun allSessionIds(): Set<SessionId> = throw NotImplementedError()
}
}
@@ -0,0 +1,55 @@
package com.correx.core.talkie
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)
}
}
@@ -0,0 +1,69 @@
package com.correx.core.talkie
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WorkflowProposalsTest {
@Test
fun `parses a fenced proposal block and strips it from the prose`() {
val content = """
The research workflow fits best — it gathers sources and writes a report.
```json
{"propose_workflows":[{"id":"research","reason":"gather + report"}],"prompt":"Run it?"}
```
""".trimIndent()
val parsed = WorkflowProposals.parse(content)
assertEquals(1, parsed.candidates.size)
assertEquals("research", parsed.candidates.single().workflowId)
assertEquals("gather + report", parsed.candidates.single().reason)
assertEquals("Run it?", parsed.prompt)
assertTrue(parsed.cleanText.startsWith("The research workflow fits best"))
assertTrue("```" !in parsed.cleanText, "fence must be stripped from the visible turn")
assertTrue("propose_workflows" !in parsed.cleanText)
}
@Test
fun `multiple candidates keep order`() {
val content = """
```json
{"propose_workflows":[{"id":"a"},{"id":"b","reason":"second"}],"prompt":"pick"}
```
""".trimIndent()
val parsed = WorkflowProposals.parse(content)
assertEquals(listOf("a", "b"), parsed.candidates.map { it.workflowId })
assertEquals("", parsed.candidates.first().reason)
// The whole body was the block; clean text falls back to the prompt.
assertEquals("pick", parsed.cleanText)
}
@Test
fun `no block yields no proposal and untouched text`() {
val content = "Let's just think this through together in chat."
val parsed = WorkflowProposals.parse(content)
assertTrue(parsed.candidates.isEmpty())
assertEquals(content, parsed.cleanText)
}
@Test
fun `malformed json yields no proposal`() {
val content = "prose\n```json\n{not valid}\n```"
val parsed = WorkflowProposals.parse(content)
assertTrue(parsed.candidates.isEmpty())
assertEquals(content, parsed.cleanText)
}
@Test
fun `an empty candidate array is not a proposal`() {
val content = "prose\n```json\n{\"propose_workflows\":[],\"prompt\":\"x\"}\n```"
val parsed = WorkflowProposals.parse(content)
assertTrue(parsed.candidates.isEmpty())
assertEquals(content, parsed.cleanText)
}
}
@@ -0,0 +1,114 @@
package com.correx.core.talkie.l3
import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class InMemoryL3MemoryStoreTest {
@Test
fun `stores and queries entries by cosine similarity`(): Unit = runBlocking {
val store = InMemoryL3MemoryStore()
val sessionId = TypeId("session-1")
val entry1 = L3MemoryEntry(
id = "entry-1",
sessionId = sessionId,
turnId = "turn-1",
text = "hello world",
vector = floatArrayOf(1f, 0f, 0f),
timestampMs = 100L
)
val entry2 = L3MemoryEntry(
id = "entry-2",
sessionId = sessionId,
turnId = "turn-2",
text = "goodbye world",
vector = floatArrayOf(0.9f, 0.1f, 0f),
timestampMs = 200L
)
val entry3 = L3MemoryEntry(
id = "entry-3",
sessionId = sessionId,
turnId = "turn-3",
text = "far away",
vector = floatArrayOf(0f, 0f, 1f),
timestampMs = 300L
)
store.store(entry1)
store.store(entry2)
store.store(entry3)
val query = L3Query(vector = floatArrayOf(1f, 0f, 0f), k = 2)
val hits = store.query(query)
assertEquals(2, hits.size, "Should return k=2 results")
assertEquals("entry-1", hits[0].entry.id, "Closest match should be entry-1")
assertEquals("entry-2", hits[1].entry.id, "Second match should be entry-2")
}
@Test
fun `existsByTurnIdPrefix returns true when entry turnId starts with prefix`(): Unit = runBlocking {
val store = InMemoryL3MemoryStore()
val sessionId = TypeId("session-1")
store.store(
L3MemoryEntry(
id = "e1", sessionId = sessionId, turnId = "repomap:/repo:git:abc123",
text = "foo", vector = floatArrayOf(1f), timestampMs = 1L
)
)
assert(store.existsByTurnIdPrefix("repomap:/repo:git:abc123")) { "should match exact" }
assert(store.existsByTurnIdPrefix("repomap:/repo:git:")) { "should match prefix" }
}
@Test
fun `existsByTurnIdPrefix returns false when no entry matches`(): Unit = runBlocking {
val store = InMemoryL3MemoryStore()
val sessionId = TypeId("session-1")
store.store(
L3MemoryEntry(
id = "e1", sessionId = sessionId, turnId = "project:/repo",
text = "foo", vector = floatArrayOf(1f), timestampMs = 1L
)
)
assert(!store.existsByTurnIdPrefix("repomap:/repo:git:")) { "should not match different prefix" }
}
@Test
fun `respects sessionIdFilter`(): Unit = runBlocking {
val store = InMemoryL3MemoryStore()
val session1 = TypeId("session-1")
val session2 = TypeId("session-2")
val entry1 = L3MemoryEntry(
id = "entry-1",
sessionId = session1,
turnId = "turn-1",
text = "session 1",
vector = floatArrayOf(1f, 0f),
timestampMs = 100L
)
val entry2 = L3MemoryEntry(
id = "entry-2",
sessionId = session2,
turnId = "turn-1",
text = "session 2",
vector = floatArrayOf(1f, 0f),
timestampMs = 200L
)
store.store(entry1)
store.store(entry2)
val query = L3Query(vector = floatArrayOf(1f, 0f), k = 10, sessionIdFilter = session1)
val hits = store.query(query)
assertEquals(1, hits.size, "Should only return entry from session1")
assertEquals("entry-1", hits[0].entry.id)
}
}