The router/narration model behaviour was hardcoded (Main built RouterConfig()
with defaults). Surface it under the [router] section so it's tunable without a
rebuild:
[router] conversation_keep_last, retrieval_k, token_budget
[router.generation] temperature, top_p, max_tokens (chat/steering)
[router.narration] temperature, top_p, max_tokens, max_per_run
ConfigLoader parses the new sections (adds asDouble); Main maps the config-layer
RouterConfig onto the domain RouterConfig and threads narration.max_per_run into
ServerModule. All values default to the previous constants, so behaviour is
unchanged when the sections are absent. Documents the block in sample-config.toml
and adds parser tests for present/absent cases.
Live QA showed two problems with paused-approval narration:
- The narrator was told to "name the stage, outcome, and reason" but the pause
trigger only carried "APPROVAL_PENDING" with no detail — on a first-stage pause
there's no L2 history either, so it had nothing to ground on. NarrationSubscriber
now captures the latest ApprovalRequestedEvent per session (tool name, tier,
preview) and folds it into the pause instruction (preview truncated to 800
chars).
- Narration reused the 512-token chat generation config; a reasoning model spent
the whole budget "thinking" and returned empty content (finishReason=length).
Add a separate narrationGenerationConfig (maxTokens=1024) so thinking can
complete and still leave room for the one or two sentences.
Adds a subscriber test asserting the pause narration names the tool and includes
the preview.
Add TypeRouterNarration constant, mark it event-bearing, decode golden test,
applyServer case appending RouterEntry{Role:"narration_llm"} with metrics, and
routerRows render branch showing bright ◆ prefix with FgStrong content + metrics suffix.
ChatTurn wire frame now includes latencyMs: Long? and totalTokens: Int?.
DomainEventMapper reads them from ChatTurnEvent.latencyMs and
tokensUsed?.totalTokens. Positional call site in DomainEventMapperTest
converted to named args. Two new serialization tests cover the ROUTER
(metrics present) and USER (metrics absent) paths.
Stage started/completed/failed events are now injected into the router
transcript (role "stage") and rendered in Faint colour, giving the
operator a chronological in-feed view of workflow progress alongside
user/router/tool turns.
Wires the workspace handshake end to end so a session's workspace is bound
from the client's cwd at connect time and is event-sourced for replay.
- Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect
(protocol.go encoder + client.go connect path; golden test pins the wire
format against the Kotlin discriminator).
- Server adds ClientMessage.Hello, stashes the per-connection workingDir, and
on session start resolves it through WorkspaceResolver's trust pipeline,
emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved
workspace into OrchestrationConfig for the live run. A Hello after the first
StartSession is ignored (warn); a rejected path binds the resolver fallback.
- Replay derives the workspace from the recorded event: SessionState gains
boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent,
and ReplayOrchestrator uses it (Path.of only, no filesystem re-query —
invariant #8) with graceful fallback to config for pre-Phase-B logs.
Absent Hello / null resolver degrades to the prior config-workspace behavior.
SessionWorkspaceBoundEvent already recorded the bound workspace root but
it was never surfaced. Map it to a new session.workspace_bound frame
(sealed ServerMessage variant, auto-registered; re-emitted on reconnect
via the snapshot replay), carry it on Session.WorkspaceRoot, and show it
home-abbreviated in the status bar (⌂ ~/Programs/correx). Golden test
pins the decode.
The resource gauge was NVIDIA-only — on an AMD/ROCm box Main fell back to
UnavailableProbe, so the TUI showed no VRAM. Add AmdResourceProbe backed
by `rocm-smi --showmeminfo vram --showuse --csv`: it skips the warning
preamble, locates the header by column name (order/version tolerant), and
converts the byte VRAM figures to MiB. Process RSS reuses /proc/<pid>/status
(only populated for a managed-model pid). Main now selects NVIDIA → AMD →
Unavailable. Tests cover the real ROCm 4.0 CSV + reordered columns.
The steer note replaced the action row while editing and vanished once
you esc'd out — so you couldn't see the note or the approve/reject keys,
and it was unclear the note still applied (it did: decide() reads the
buffer). Now the note is a persistent line above the action row (editable
with a caret while typing, a dim reminder once set), and the action row
stays visible. Editing hints clarify enter=approve+send, esc=keep note.
- Events were hard-capped to the last 7 per session in addEvent, so the
e-inspector and EVENTS panel could never show more. Raise to 1000
(effectively all, bounded); the EVENTS panel now shows the latest that
fit (tail), and the inspector scrolls a window around the selection
with a position indicator.
- The TUI had no approval.resolved handling at all — the decision frame
was ignored and pending only cleared on session.resumed. Add the
constant + case: clear the gate and record an ApprovalResolved event
(APPROVED/REJECTED/AUTO_APPROVED + reason) into the stream.
- Status bar now shows the current stage and session status alongside the
name. Relabel the cryptic "N bg" badge to "N elsewhere".
Three QA-found issues in the approval gate:
- displayState gated the in-session/approval surfaces on hasApproval
before sessionEntered, so moving the list cursor onto a session with a
pending gate auto-opened it, and `l` back-to-list couldn't escape
(the gate re-popped). Require sessionEntered first.
- The band ran every preview through the two-column diff renderer, so a
shell tool's argv JSON rendered as an identical-column "diff". Detect
real unified diffs (isUnifiedDiff); render other previews as plain
text, and drop the ^x fullscreen hint when there's nothing to expand.
- The footer duplicated the band's approve/reject/steer/diff keys. It now
shows navigation (l back / e events / q quit) instead.
The plane-2 tool-call assessor records verified preconditions
("[PATH_OUTSIDE_WORKSPACE] …") and the server already ships them in
RiskSummaryDto.rationale, but the Go TUI's RiskSummaryDto had no
Rationale field — so the justification was silently dropped at decode
and the gate showed only an opaque tier.
Decode the rationale, carry it on Approval, and render it under the
header in the approval band (warn-marked, above the diff). This closes
the last deferred item of the plane-2 slice-1 plan: the assessment
surfaced to the approval UX. Golden test pins the rationale decode.
Replace the floating approval modal (which blanked the whole UI) with an
opencode-style band that takes the input bar's slot while a gate is
pending: tool/tier/risk header above a side-by-side old|new diff, with
the session output and event stream still visible above it. The band
height adapts to the diff length.
Add a unified-diff parser that aligns removals/additions into split rows
(blank left cell for a create, blank right for a delete) and a
two-column renderer shared by the band preview and the ^x fullscreen
view. Wire plain a/r to approve/reject to match the advertised keys.
The diff overlay incremented diffScrollOffset on every down-key with no
upper bound; the render clamped only the displayed offset, so the stored
value grew unbounded and the up-key appeared dead for N presses. Clamp
the increment against a new diffMaxScroll() (len(lines) - body height),
shared with the render path via diffBodyHeight().
Rebuild the Go TUI transcript from the event stream instead of
optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn
(chat.turn); the TUI auto-vivifies sessions and renders user+router turns
from events. SessionStarted is de-special-cased into SessionAnnounced
(still carries workflowId), dropping the router.response shadow and
optimistic echoes.
Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go
golden-fixture test to lock the wire protocol. Gitignore server runtime
logs.
Compute the effective tool registry/executor and plane-2 WorkspacePolicy per run
from config.workspace (effectivesFor), threaded through the orchestrators; a null
workspace falls back to the boot instances byte-for-byte. Wire the concrete
WorkspaceToolRegistryProvider in Main (buildToolConfigForWorkspace). Session undo
unions the session's recorded workspace into its jail roots. (Axis 2 Phase A, tasks 3 + 7.)
Resolve a client-supplied workspace dir safely: canonicalize via toRealPath,
reject privileged locations, clamp to [tools] allowed_workspace_roots (permissive
when unset, logged), fall back to the boot default on any rejection. PathJail made
non-internal so the server can reuse its symlink-safe containment. (Axis 2 Phase A, task 4.)
Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
- gate session-list nav on idle so in-session arrows/jk don't move the list (5a)
- approval dismiss on esc + in-session 'a' reopens a pending approval (5b)
- auto-focus a newly started workflow session (was running invisibly)
- approval actions on Ctrl chords (^a/^r/^x) matching the modal; bare/Alt letters no longer approve; diff closes on ^x/esc
- steer hint in the approval modal; session UUID in the input bar
- env-gated debug logging (CORREX_TUI_LOG) with k.Alt in the key trace
Go TUI decodes model.changed / model.list / resource.status and renders a
status-bar VRAM/GPU%/RAM gauge plus a models overlay (m key / palette 'models'):
lists configured models with the resident one marked, enter swaps (SwapModel),
c clears the pin (ClearModelPin). All three new server messages are
non-event-bearing control/gauge frames, applied immediately like
provider.status_changed.
Server addition required for the picker: ServerMessage.ModelList (model.list,
NonEventMessage) sent once in the initial snapshot from
ManagedInferenceRouter.availableModelIds()/currentModelId() — the TUI otherwise
cannot enumerate swap targets.
Completes the 5-slice model-lifecycle feature. ./gradlew check green; go build/vet clean.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 5 of 5).
Vendor-agnostic ResourceProbe (commons): NvidiaResourceProbe reads nvidia-smi
(injectable runner) for whole-device VRAM/util and /proc/<pid>/status for the
managed llama-server RSS, both fail-soft to null; UnavailableProbe for non-GPU
hosts / the static path. DefaultModelManager.currentPid() + LlamaProcess.pid
expose the managed pid. ServerMessage.ResourceStatus (resource.status,
NonEventMessage, all-nullable) pushed every 2.5s on the global stream plus one
in the initial snapshot. Live gauge only — never event-sourced (feeds no core
decision), so invariants #8/#9 hold. Main wires the NVIDIA probe on the managed
path when nvidia-smi is present, else UnavailableProbe.
Tests: csv parsing (single/multi/malformed), gpu+rss combine, fallbacks.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 4 of 5).
ManagedInferenceRouter owns a live @Volatile pin (decision D1): swap(modelId)
makes a model resident and pins it; clearPin() releases it. Pin is highest
precedence in route() (pin > stage.modelId > capability > default). New
SwapModel/ClearModelPin client messages handled in GlobalStreamHandler via
ServerModule.modelSwapper (null on the static path). ServerMessage.ModelChanged
(model.changed) mapped from ModelLoadedEvent/ModelUnloadedEvent — the swap's load
event surfaces to clients through streamGlobal, so the handler doesn't push it
directly. Tests: pin precedence + swap/clear, Model* -> ModelChanged.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 3 of 5).
StageConfig.modelId; InferenceRouter gains a backward-compatible model-aware
route() overload (default ignores modelId, so static routers and test fakes are
unaffected). New ManagedInferenceRouter (infra commons) resolves a target model
per stage (stage.modelId > capability match > default) and ensureLoaded()s it
before routing, returning the live managed provider — its id changes with the
resident model, so there is no stale health cache to invalidate on swap.
ModelManager.ensureLoaded default delegates to the idempotent load(). Main wires
the managed router on the [[models]] path; the static path keeps DefaultInferenceRouter.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 2 of 5).
[[models]] config (ModelConfig/ModelsSettings) parsed by ConfigLoader;
InfrastructureModule.createModelManager + modelConfigToDescriptor; Main.kt
managed boot path spawns the default llama-server when [[models]] is present
(static [[providers]] path preserved when absent) and kills it on shutdown.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 1 of 5).
TurboVec persists vectors via the sidecar but kept entry metadata
(sessionId/turnId/text) only in an in-memory map, so restarting with
backend=turbovec silently dropped every query hit (the id→metadata
lookup missed). Rebuild that map from the ChatTurnEvent log at startup.
- RehydratableL3MemoryStore: capability interface for stores whose
vectors persist independently of the JVM. TurboVec implements it;
InMemory deliberately does not (its vectors are volatile too).
- L3MetadataRehydrator replays ChatTurnEvents into the metadata map.
No embedder: the query path never reads entry.vector, so vectors
stay in the sidecar and metadata comes from the event log (the
source of truth, invariant #1). Short-circuits for non-rehydratable
backends before scanning the log.
- Wired into Main before the server accepts queries; no sidecar spawn.
Backfill of never-embedded history (needs the embedder) is a separate
follow-up. The in_memory non-durability warning already exists.
Final plane-2 step: the tool-call intent assessment was decided
server-side but never reached the client — DomainEventMapper dropped
ToolCallAssessedEvent through the else/null branch.
- ServerMessage.ToolAssessed (tool.assessed): disposition as a plain
String + AssessedIssueDto list; observations stay off the wire
(internal replay facts, invariant #9).
- DomainEventMapper maps the event 1:1 (no filtering — rendering
decisions belong in the client).
- Go TUI consumes tool.assessed, records an event entry only for
non-PROCEED or issue-bearing assessments (noise control), and is
added to IsEventBearing() so it buffers correctly during snapshot
replay like its sibling tool.* events.
Implements the full vertical slice for invariant #9 tool-call assessment:
- core:toolintent — new module with ToolCallRule seam, ToolCallAssessor,
WorldProbe/FileSystemWorldProbe, WorkspacePolicy, PathContainmentRule,
and RiskMapping (assessment → RiskSummary / AssessedIssue)
- core:tools — ToolCallAssessmentRecord + ToolInvocationRecord.assessment
field; DefaultToolReducer handles ToolCallAssessedEvent (replay proof)
- core:config — ToolsConfig gains workspaceRoot + privilegedLocations;
ConfigLoader parses both; DEFAULT_PRIVILEGED_LOCATIONS built-in
- core:kernel — OrchestratorEngines gains toolCallAssessor/workspacePolicy/
worldProbe fields; SessionOrchestrator.dispatchToolCalls runs
runPlane2Assessment before the tier gate: BLOCK → hard-reject without
executing; PROMPT_USER → elevates tier into approval path with plane2Risk
in the ApprovalRequestedEvent
- apps/server — constructs PathContainmentRule + ToolCallAssessor +
WorkspacePolicy from config and wires them into OrchestratorEngines
Assessment is recorded as ToolCallAssessedEvent (environment observed once,
facts stored, replay reads events — invariant #9). Assessor and WorldProbe
are only invoked on the live orchestrator path, never in replay.
Remove the wall-clock approval timeout that auto-rejected pending approvals.
The timeout modeled machine latency, but approvals are human latency
(unbounded); it also created an intent/outcome divergence where a human
approving at the same instant the timeout fired was silently overridden by
the auto-reject. Approvals now block until the operator decides; the
resolved-request dedup still guards against double-submit.
Add ServerMessage.ApprovalResolved and map ApprovalDecisionResolvedEvent to
it in DomainEventMapper so clients are notified when any approval is resolved
(by anyone) — letting a second connected client clear its prompt. This is the
event-sourced replacement for the removed timeout notification path.
Drop the now-dead ApprovalConfig (timeout_ms) and its loader/test/sample-config
references. ApprovalStatus.TIMED_OUT is retained for replay of historical events.
Carry the full RiskSummary (level, signals, rationale) inline on
ApprovalRequestedEvent instead of discarding it after assessment. The two
server-side DTO builders (live ApprovalCoordinator path and replay
DomainEventMapper path) now render real risk level, recommended action,
signal factors, and the [code] rationale lines into the approval WebSocket
message, with a consistent enum-name fallback when no assessment ran (tool
path). RiskSummary/RiskSignal made @Serializable for event embedding;
shared RiskSummary.toDto() lives next to RiskSummaryDto.
Flags when a workflow stage's allowedTools references a tool name that
is not registered in the tool registry (config drift), surfaced as a
non-blocking WARNING.
- Add ValidationContext.availableTools (nullable; null = registry
unavailable, skip the check)
- MissingToolRule emits one MISSING_TOOL warning per (stage, tool) pair,
deterministically ordered
- Populate availableTools from the ToolRegistry at the orchestrator
construction site; wire the rule into the prod SemanticValidator
Wire SemanticValidator into the production validator pipeline and
consolidate cycle-policy types under core:validation.
- Add SemanticValidator(CycleExitRule(requirePolicyForCycles=false))
to the prod ValidationPipeline (no-op default, preserves behavior)
- Move CyclePolicy/Binding/Signature/Factory from core:transitions.policy
to core:validation.policy (validation already owns ValidationContext)
- Rename CyclePolicyBindingRule -> CycleExitRule (issue code unchanged)
- Delete dead CyclePolicyResolver + PolicyValidation
Adds [router.embedder] and [router.l3] sections to CorrexConfig with
backend selectors. Ships LlamaCppEmbedder that hits llama.cpp's
/embedding endpoint (handles OpenAI-compatible, simple, and array
response shapes; validates dimension). InfrastructureModule gains
createEmbedderFromConfig and createL3MemoryStoreFromConfig that
dispatch on backend value.
Defaults preserve current behavior (noop embedder + in-memory L3).
Switching to "llamacpp" / "turbovec" is a config-only change — no code
edits required. For turbovec backend, the bundled python sidecar
script is extracted from classpath to ~/.cache/correx/ on first use.
Adds ProviderConfig + providers list and per-tool enabled flags to
CorrexConfig, and rewires apps/server/Main to build the provider list
from config instead of the hardcoded buildLlamaProvider() factory. Env
vars (CORREX_MODEL_ID, CORREX_MODEL_PATH, CORREX_LLAMA_URL) remain a
fallback only when no providers are declared in config.
Completes slice A of the config layer alongside commit 0834c70 which
already shipped the TOML parser upgrade and sample config — those
parser changes referenced these types but were committed prematurely
in isolation; this commit makes HEAD buildable.