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:
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user