| """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" |
| |
| |
| |
| |
| |
| |
| "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"<think>.*?</think>", re.DOTALL | re.IGNORECASE) |
|
|
|
|
| def _clean(text: str) -> str: |
| text = (text or "").strip() |
| |
| |
| text = _THINK_RX.sub("", text).strip() |
| |
| |
| if "<think>" in text.lower(): |
| idx = text.lower().rfind("<think>") |
| text = text[idx + len("<think>"):].lstrip() |
| |
| 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_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] = [] |
| |
| 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] |
|
|