WillHbx's picture
feat(memory): structured budget fit, milestone-50 hint, pacing nudge
b08778c
Raw
History Blame Contribute Delete
7.26 kB
"""Memory is a *budget*, not an archive.
Every turn we assemble the user-side context from (in priority order): the rolling summary,
the current scene, the present characters' sheets, the last k verbatim turns, and the player's
new line β€” trimmed to a conservative token budget. Older turns get folded into `summary` by
`orchestrator.compact_memory`. This is the literal "Thousand Token Wood".
"""
from __future__ import annotations
from . import characters, config
from .schemas import GameState
# Flags that drive the engine/UI, not story facts β€” never surfaced in the FACTS: line.
_INTERNAL_FLAGS = {
"current_music",
"situation_intro",
"beat_started_turn",
"ending_kind",
"ending_text",
"player_name",
}
_INTERNAL_PREFIXES = ("milestone50_",)
def assemble_context(state: GameState, player_input: str, action_note: str = "") -> str:
parts: list[str] = [] # the head: never trimmed (style, scene, sheets, milestones)
parts.append(f"WORLD STYLE: {state.style_guide}")
parts.append(f"VIBE: {state.vibe} | BEAT: {state.beat}")
player_name = state.flags.get("player_name", "")
if player_name:
parts.append(f'The player is called "{player_name}" - characters address them by name.')
if state.summary:
parts.append(f"THE TALE SO FAR:\n{state.summary}")
parts.append(
f"WHERE WE ARE:\n- place: {state.scene.place}\n"
f"- description: {state.scene.description}\n- mood: {state.scene.mood}"
)
parts.append(f"SPIRITS ON STAGE:\n{characters.present_sheets_block(state)}")
# Characters the player has already met but who are currently off-stage.
# The LLM can bring them back via new_character (same id) β€” data is preserved.
off_stage = [
f" - {ch.name} (id: {ch.id}, rel: {ch.relationship:+d})"
for cid, ch in state.characters.items()
if cid not in state.scene.present
]
if off_stage:
parts.append(
"KNOWN BUT OFF STAGE (can return β€” use their existing id):\n" + "\n".join(off_stage)
)
# ── Milestone-50 special moment β€” one-shot hint armed by apply_directives ──
for ch in state.characters.values():
if ch.id in state.scene.present and state.flags.get(f"milestone50_{ch.id}") == "pending":
parts.append(
f"*** GROWING CLOSE: {ch.name}'s affection just reached 50+/100. ***\n"
f"This turn, have {ch.name} initiate a small special moment: suggest going "
f"somewhere together (emit scene_change), confide a small secret, or give "
f"the player an affectionate nickname. Make it feel earned, not sudden."
)
# ── Pacing nudge β€” deterministic, fires when a beat lingers too long ──────
turns_in_beat = state.turn_index - int(state.flags.get("beat_started_turn", "0") or 0)
if turns_in_beat >= 7 and state.beat in ("opening", "rising", "turn"):
parts.append(
f"PACING: {turns_in_beat} turns in beat '{state.beat}' - the scene is "
f"lingering. Consider advance_beat=true this turn."
)
# ── Ending milestone hints β€” injected when a character hits Β±100 ──────────
for ch in state.characters.values():
if ch.relationship >= 100 and ch.id in state.scene.present:
parts.append(
f"*** ROMANCE PEAK: {ch.name}'s affection is at MAX (100/100). ***\n"
f'This turn MUST be their heartfelt confession. Emit ending(kind="warm") '
f'with a poetic 2-4 sentence epilogue. Also emit set_music="romantic".'
)
elif ch.relationship <= -100 and ch.id in state.scene.present:
parts.append(
f"*** RELATIONSHIP BROKEN: {ch.name} is at MIN (-100/100). ***\n"
f'They storm off with a final cutting line. MUST emit exit_character="{ch.id}". '
f'If the stage becomes empty after this, also emit ending(kind="defeat") '
f'with a dark 2-4 sentence epilogue. Emit set_music="sad".'
)
# If scene already empty and not yet ended β€” nudge for defeat
if not state.scene.present and state.beat != "ended" and state.characters:
parts.append(
"*** THE STAGE IS EMPTY β€” the wanderer is completely alone. ***\n"
'Emit ending(kind="defeat") with a melancholic epilogue. Emit set_music="sad".'
)
# Surface the current music track prominently so the LLM doesn't switch it needlessly.
current_music = state.flags.get("current_music")
if current_music:
parts.append(
f'CURRENT BGM: "{current_music}" β€” leave set_music null unless a MAJOR tonal '
f"shift justifies a change (max once every 6-8 turns)."
)
other_flags = {
k: v
for k, v in state.flags.items()
if k not in _INTERNAL_FLAGS and not k.startswith(_INTERNAL_PREFIXES)
}
if other_flags:
parts.append("FACTS: " + ", ".join(f"{k}={v}" for k, v in other_flags.items()))
# All turns since the last compaction β€” bounded by compaction (oldest get folded
# into `summary`) and by the budget below (oldest lines dropped first).
recent_lines = [
f'wanderer: "{t.player}"\n{t.speaker}: "{t.dialogue}"' for t in state.recent_turns
]
tail_parts = []
if action_note:
# Kept OUTSIDE the quoted player line β€” inside the quotes the model reads
# directives as player speech and ignores them.
tail_parts.append(f"DIRECTOR NOTE (mandatory):\n{action_note}")
tail_parts += [
f'{(player_name or "the wanderer").upper()} NOW SAYS: "{player_input.strip() or "…"}"',
"Reply as one present spirit, then emit the directives JSON. "
f"You may speak as one of: {characters.actor_roster(state)}.",
]
return _fit_to_budget(parts, recent_lines, tail_parts, config.CONTEXT_TOKEN_BUDGET)
def should_compact(state: GameState) -> bool:
# Compaction keeps RECENT_TURNS_K turns verbatim; trigger once at least as many
# again have piled up, so each compaction call folds a meaningful chunk.
return len(state.recent_turns) > 2 * config.RECENT_TURNS_K
def _fit_to_budget(
head_parts: list[str],
recent_lines: list[str],
tail_parts: list[str],
token_budget: int,
) -> str:
"""Rough charβ‰ˆtoken/4 budget. Drops the oldest RECENT EXCHANGE lines first; the
system-critical head (style, scene, sheets) and the player's line are preserved.
Hard-clamps keeping the tail only as a last resort (head alone over budget)."""
char_budget = token_budget * 4
def _join(lines: list[str]) -> str:
parts = list(head_parts)
if lines:
parts.append("RECENT EXCHANGE:\n" + "\n".join(lines))
parts.extend(tail_parts)
return "\n\n".join(parts)
ctx = _join(recent_lines)
if len(ctx) <= char_budget:
return ctx
lines = list(recent_lines)
while lines:
lines = lines[1:]
ctx = _join(["…(earlier exchange trimmed)…", *lines])
if len(ctx) <= char_budget:
return ctx
return "…(earlier dream trimmed)…\n\n" + ctx[-char_budget:]