"""Stream of Consciousness — the bridging narrative between a character's
goal and their concrete actions.
Refreshed every SOC_EVERY_N decisions per character. One LLM call:
inputs are the character's identity (personality, archetype, journal,
traits, current goal) and their last 10 trace entries. Output is 2-3
sentences of first-person inner monologue.
The result is stored on the character and:
- Surfaced in the side panel between Goal and Trace
- Injected into the character's own perception prompt on subsequent
decisions so they "remember" their own reasoning
Mock backends emit the SoC like any other LLM call — see the mock's
generate_messages, which detects the SoC prompt and returns a
context-appropriate canned monologue.
"""
from __future__ import annotations
import re
import traceback
from backend.factory import get_llm
from .character import Character
from .log import tlog
from .the_dot import THE_DOT
from .world import WorldState
SOC_EVERY_N = 10
SOC_MAX_TOKENS = 220
def maybe_refresh_soc(world: WorldState, char: Character) -> bool:
"""Increment the per-character SoC counter; refresh and return True if
we crossed the threshold. The caller catches exceptions.
"""
char.decisions_since_soc += 1
if char.decisions_since_soc < SOC_EVERY_N and char.stream_of_consciousness:
return False
try:
char.stream_of_consciousness = _generate(world, char)
char.decisions_since_soc = 0
return True
except Exception:
tlog(f"[townlet] SoC refresh failed for {char.name}:")
traceback.print_exc()
return False
def _generate(world: WorldState, char: Character) -> str:
backend = get_llm(char.model_id)
user = _build_prompt(char)
raw = backend.generate_messages(
messages=[
{"role": "system", "content": THE_DOT + "\n\nYou are about to write a moment of inner monologue."},
{"role": "user", "content": user},
],
max_tokens=SOC_MAX_TOKENS,
)
return _clean(raw)
def _build_prompt(char: Character) -> str:
journal_tail = "\n".join(f"- {e}" for e in char.journal[-5:]) or "(no journal entries yet)"
recent = char.trace[-10:]
if recent:
recent_text = "\n".join(
f" tick {e.tick}: " + ", ".join(
f"{tc['verb']}({_short(tc.get('args', {}))})" for tc in (e.tool_calls or [])[:2]
)
for e in recent
)
else:
recent_text = " (you haven't acted yet)"
return (
f"You are {char.name}, an inhabitant of Townlet ({char.archetype}).\n\n"
f"Personality:\n{char.personality}\n\n"
f"Journal (insights from your dreams):\n{journal_tail}\n\n"
f"Your current goal:\n{char.goal}\n\n"
f"Your most recent actions:\n{recent_text}\n\n"
# Small models (esp. SmolLM2-360M) treat "Write 2-3 sentences of inner
# monologue" as a meta-instruction and parrot it back ("The user wants
# a 2-3 sentence inner monologue..."). The few-shot exemplars below
# show what good output looks like; the closing line nudges them to
# IMMEDIATELY start a sentence in-character. Reinforced by _clean's
# meta-leak filter.
"Two examples of the form of reply expected:\n"
" Example A: \"The cave is closer than the well; I'll start there before anyone else does.\"\n"
" Example B: \"I keep thinking about what Ada said. If I'm not careful, the shell will lock me out again.\"\n\n"
f"Now {char.name} thinks to themselves (begin immediately, first person, one paragraph, no preamble):"
)
def _short(args: dict) -> str:
out = []
for k, v in args.items():
if k == "_positional":
out.append(", ".join(str(x) for x in v))
else:
out.append(f"{k}={v}")
return ", ".join(out)
_THINK_RX = re.compile(r".*?", re.DOTALL | re.IGNORECASE)
def _clean(text: str) -> str:
text = (text or "").strip()
# Nemotron 3 Nano and other reasoning models wrap chain-of-thought in
# ... blocks; the final answer follows. Drop the block.
text = _THINK_RX.sub("", text).strip()
# If the reply was truncated mid-reasoning (open tag, no close), drop
# everything before the dangling opener so we don't surface meta-text.
if "" in text.lower():
idx = text.lower().rfind("")
text = text[idx + len(""):].lstrip()
# Strip markdown code fences if the model wrapped its reply in them.
if text.startswith("```"):
text = text.lstrip("`").lstrip()
if "\n" in text:
text = text.split("\n", 1)[1]
if text.endswith("```"):
text = text.rstrip("`").rstrip()
# Meta-leak filter: small models sometimes echo the instruction
# ("The user wants...", "We need to produce...", "I'll write a 2-3
# sentence monologue..."). Drop any leading sentence containing those
# phrases; if the whole reply is meta, fall back to silence.
META_MARKERS = (
"the user wants",
"we need to produce",
"we need to output",
"i need to output",
"i need to write",
"i need to craft",
"i need to produce",
"i'll write",
"i will write",
"let me craft",
"let me write",
"let's craft",
"let's write",
"should reflect",
"should be in",
"2-3 sentence",
"2 to 3 sentence",
"inner monologue",
"first person",
"first-person",
"as instructed",
"as requested",
)
out_sentences: list[str] = []
# Naive split keeps periods inside sentences but is good enough for SoC.
for sentence in text.replace("\n", " ").split(". "):
low = sentence.lower()
if any(m in low for m in META_MARKERS):
continue
s = sentence.strip()
if s:
out_sentences.append(s if s.endswith((".", "!", "?")) else s + ".")
cleaned = " ".join(out_sentences).strip()
return cleaned[:1200]