session_controller.go is the runtime core of llm_core. It owns the turn loop, the prompt assembly, the speaker selection, the image pipeline, the memory system, and the objective governance engine. Every other module — LLM clients, ComfyUI, OpenSearch, the experiment config — gets wired in here and called from here.
Nothing in this file is specific to any one experiment. The behavior is entirely driven by the *experiment.Experiment struct loaded from the YML file at startup. Swap the YML and the controller runs a completely different simulation — different personas, different compression rules, different governance behavior, different models — without changing a single line of Go.
main.go loads the infrastructure config and the experiment YML, builds all the clients, and calls controller.RunDynamicConversation(). From that point forward the controller owns execution. It runs every turn, calls every pass, writes every document, and returns a SessionResult when the run is complete.
Holds a pointer to the loaded experiment config. Created once via NewController(exp), used for the entire run.
Lightweight runtime record — persona name, turn number, response text. Accumulated in contextHistory and fullTranscript.
Returned to main.go at the end of the run. Contains the full ordered transcript of all turns. Used for final display and post-processing.
The complete per-turn document written to OpenSearch. Contains every artifact produced in a single turn — reply, reflection, compression, image, diagram, objective state.
Every turn produces one TurnIndexDoc written to the llm_turns_v1 OpenSearch index. This is the permanent record of what happened — not just the reply text but the full cognitive state, the prompt that produced it, the objective at the time, and every artifact generated downstream.
| Field | Go Type | JSON Key | Description |
|---|---|---|---|
| SessionID | string | session_id | Nanosecond timestamp string generated at run start. Groups all turns from one run together. |
| SimTitle | string | sim_title | Human-readable title from experiment.title. Displayed in the frontend session picker. |
| TurnNumber | int | turn_number | 1-indexed position in the run. Used for sort ordering in OpenSearch queries. |
| Speaker | string | speaker | Name of the persona that produced this turn's output. |
| Entropy | int | entropy | Running count of external RSS signals injected so far in this run. |
| PromptText | string | prompt_text | The complete assembled prompt sent to the generation model. Collapsible in the frontend via "View prompt". |
| ReplyText | string | reply_text | The generation model's response after think-block stripping. Primary content displayed in the transcript. |
| ReflectionText | string | reflection_text | 5-line structured state block from the critic model. Not shown to end users — feeds compression and objective synthesis. |
| CompressionText | string | compression_text | Updated memory snapshot for this persona after this turn. Injected as MEMORY/DESIGN STATE in the next turn's prompt. |
| ExternalSignal | map[string]interface{} | external_signal | The RSS item injected this turn if entropy was active — title, description, category. |
| ImageURL | string | image_url | Production path to the ComfyUI-rendered image. Served directly by Nginx. |
| DiagramText | string | diagram_text | Mermaid graph definition generated from the reply. Rendered client-side as an interactive SVG diagram. |
| GlobalObjective | string | global_objective | The current experiment objective at the time of this turn. Changes on escalation. |
| GlobalObjectiveVersion | int | global_objective_version | Increments each time the objective changes. Tracks phase transitions across the full run. |
| GenerationModel | string | generation_model | Model name reported by the generation client. Stored per-turn so mixed-model runs are queryable. |
| CriticModel | string | critic_model | Model name reported by the reflection client. |
| Timestamp | time.Time | timestamp | Wall time when the turn completed. Used for session ordering and the 5-second live polling window. |
The controller has no hardcoded behavior. Every decision about what to do on a given turn — which prompt headers to use, what compression rules apply, whether governance mode is active, which image prefix to prepend — comes from the *experiment.Experiment struct stored in c.exp.
This means the controller is a generic runtime. The experiment YML is the program. You can run a medieval dragon story, a telecom architecture session, a fake news comedy panel, or a technical problem analysis by swapping one file. The Go code does not change.
Cycles, context window, inject_news flag, full_control flag, make_images flag. These map directly to the arguments passed through RunDynamicConversation() from main.go.
Each persona becomes a storage.PersonaState with name, persona text, core invariant, and compression mode override. The personaMemory map is keyed on persona name — each gets its own independent memory lane.
All section headers injected by buildPrompt() — SYSTEM, CORE INVARIANT, MEMORY, OBJECTIVE, EXTERNAL SIGNAL, ENGAGEMENT, INSTRUCTION — come from the YML. Changing the header text changes how the model parses the prompt structure.
Two compression modes — cognitive and problem — each with their own preserve, strip, and output rules. The controller selects the right mode per persona and passes the full config to RunContextCompressionPass().
When full_control is enabled, the governance block is appended to every prompt. All text — preamble, definition header, rules, proposal format — comes from the YML. Nothing is hardcoded in the controller.
Style flag selects technical or cinematic prefix. generate_diagram flag enables Mermaid generation. Both prefixes are full prompt text from the YML — the controller just prepends the right one.
The experiment.LoadExperiment(path) function in main.go reads, parses, and validates the YML before the controller ever runs. By the time NewController(exp) is called, the struct is fully populated and validated. The controller never reads the file directly — it only reads c.exp.
The main loop runs exactly totalTurns iterations. Each iteration is deterministic — the same sequence of operations in the same order every time. The only sources of non-determinism are speaker selection (random from candidates), entropy injection timing, and the LLM outputs themselves.
Every prompt sent to the generation model is assembled fresh each turn from live state. Nothing is cached. The function takes the experiment config, the speaking persona's data, the current objective, and the speaker's memory snapshot, and builds a structured plain-text prompt by concatenating YML-sourced sections in a fixed order.
The SYSTEM header, persona text, and core invariant are constants — they never change turn to turn for a given persona. The MEMORY section is the variable that makes each turn different. It carries the compressed snapshot of everything the persona knows so far — named components in a design, canon facts in a story, reasoning stance in a debate. It's what allows a persona to build on previous turns without the controller passing the entire history into every prompt.
When full_control is enabled, every prompt gets the full governance block appended — mode label, proposal preamble, irreversible definition list, no-change instruction, proposal format, and rules. All of this text comes from c.exp.Governance in the YML. The persona reads this block and can choose to include an OBJECTIVE_PROPOSAL: in their reply. The controller then checks for it via extractObjectiveProposal().
The memory system is two passes working in sequence. First the reflection pass evaluates what just happened and produces a structured state block. Then the compression pass uses that history to update the persona's memory lane — the persistent snapshot that will be injected into every future turn for that persona.
Critic model evaluates the most recent reply against the core invariant.
Compresses context history into the persona's memory lane using YML-defined rules.
In a multi-persona experiment, each persona carries their own memory independently. Riff's compression state does not bleed into Vera's. Harlan's design state is private to Harlan. This means a persona's memory only reflects turns they actually spoke — they don't accumulate memory from other personas' turns, only from the shared context history window that gets compressed through their own lens.
contextHistory is the raw turn buffer trimmed to contextWindow size — typically 6-8 turns. This is what the compression pass reads. The personaMemory string is the distilled output — much smaller, injected directly into prompts. The context window prevents memory token explosion. The memory lane preserves continuity across turns that have already been trimmed from the window.
The objective system has two independent escalation paths. They operate in priority order — persona proposals always win, the synthesizer only fires if no proposal was made. Both paths update the same currentObjective string and increment objectiveVersion.
When full_control is false, the governance block is never appended to prompts so personas never see the proposal format and cannot produce valid proposals. The synthesizer still runs every turn — it can still escalate the objective based on reflection state. The difference is that only the system can change the objective, not the personas themselves.
The controller has two output paths. When an OpenSearch client is available, every turn document is indexed immediately after the turn completes. When no OpenSearch client is configured, the document is JSON-printed to stdout — useful for local development and debugging.
The Next.js LiveTranscript component polls /api/sessions/[sessionId]/turns every 5 seconds. The API route queries OpenSearch for all turns in the session sorted by turn_number. When the response contains more turns than the current state, setTurns(fresh) updates the component and the new turn appears in the browser. This means you can watch the simulation run in real time from any browser — the controller is writing to OpenSearch as it goes, the frontend is reading from it continuously.
When all turns complete, RunDynamicConversation() returns a SessionResult containing the full fullTranscript slice — all turns in order, never trimmed. main.go calls processResult() which prints the complete transcript to stdout as a final summary. This is separate from the OpenSearch indexing — the transcript is the human-readable output, the index is the queryable analytical record.