Spaces:
Sleeping
Sleeping
Commit ·
a2e44d7
1
Parent(s): e22b6d8
feat: B2 — Hollow resurfaces your memories unprompted
Browse filesCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +14 -2
- character.py +5 -1
- memory.py +11 -0
- tests/test_memory.py +15 -0
app.py
CHANGED
|
@@ -7,7 +7,7 @@ from character import (FRAGMENT_TONES, OPENING_LINE, OWN_FRAGMENTS,
|
|
| 7 |
build_system_prompt)
|
| 8 |
from engine import run_turn
|
| 9 |
from finale import finale_steps, finale_steps_bad, finale_steps_good
|
| 10 |
-
from memory import apply_update, get_tier, decide_ending, should_recall, style_signal
|
| 11 |
from render import render_entity, render_treasure
|
| 12 |
from voice import speak
|
| 13 |
|
|
@@ -45,6 +45,7 @@ def _pristine_state():
|
|
| 45 |
"wounds": [],
|
| 46 |
"ending": None,
|
| 47 |
"fragments_told": 0,
|
|
|
|
| 48 |
"voice_on": True,
|
| 49 |
"msg_lengths": [],
|
| 50 |
}
|
|
@@ -95,6 +96,7 @@ def _init_state():
|
|
| 95 |
"wounds": [],
|
| 96 |
"ending": None,
|
| 97 |
"fragments_told": 2 if warm else 0,
|
|
|
|
| 98 |
"voice_on": True,
|
| 99 |
"msg_lengths": [],
|
| 100 |
}
|
|
@@ -113,6 +115,7 @@ def _init_state():
|
|
| 113 |
"wounds": ["you're nothing", "talking to you is a waste of time"],
|
| 114 |
"ending": None,
|
| 115 |
"fragments_told": 0,
|
|
|
|
| 116 |
"voice_on": True,
|
| 117 |
"msg_lengths": [],
|
| 118 |
}
|
|
@@ -386,6 +389,11 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 386 |
and state.get("tone", 0) >= FRAGMENT_TONES[told]):
|
| 387 |
own_fragment = OWN_FRAGMENTS[told]
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
|
| 390 |
state["msg_lengths"] = lengths[-5:] # keep the last 5
|
| 391 |
|
|
@@ -395,7 +403,8 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 395 |
ask_for_memory=(state["turn"] >= 3
|
| 396 |
and not state["treasure"]),
|
| 397 |
own_fragment=own_fragment,
|
| 398 |
-
style=style_signal(state["msg_lengths"])
|
|
|
|
| 399 |
chat_messages = [{"role": "system", "content": system}] + state["history"] + [
|
| 400 |
{"role": "user", "content": user_msg}
|
| 401 |
]
|
|
@@ -420,6 +429,9 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 420 |
if own_fragment and not turn_failed:
|
| 421 |
state["fragments_told"] = told + 1
|
| 422 |
|
|
|
|
|
|
|
|
|
|
| 423 |
state["history"].append({"role": "user", "content": user_msg})
|
| 424 |
state["history"].append({"role": "assistant", "content": reply})
|
| 425 |
state["turn"] += 1
|
|
|
|
| 7 |
build_system_prompt)
|
| 8 |
from engine import run_turn
|
| 9 |
from finale import finale_steps, finale_steps_bad, finale_steps_good
|
| 10 |
+
from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
|
| 11 |
from render import render_entity, render_treasure
|
| 12 |
from voice import speak
|
| 13 |
|
|
|
|
| 45 |
"wounds": [],
|
| 46 |
"ending": None,
|
| 47 |
"fragments_told": 0,
|
| 48 |
+
"last_aware_memory": None,
|
| 49 |
"voice_on": True,
|
| 50 |
"msg_lengths": [],
|
| 51 |
}
|
|
|
|
| 96 |
"wounds": [],
|
| 97 |
"ending": None,
|
| 98 |
"fragments_told": 2 if warm else 0,
|
| 99 |
+
"last_aware_memory": None,
|
| 100 |
"voice_on": True,
|
| 101 |
"msg_lengths": [],
|
| 102 |
}
|
|
|
|
| 115 |
"wounds": ["you're nothing", "talking to you is a waste of time"],
|
| 116 |
"ending": None,
|
| 117 |
"fragments_told": 0,
|
| 118 |
+
"last_aware_memory": None,
|
| 119 |
"voice_on": True,
|
| 120 |
"msg_lengths": [],
|
| 121 |
}
|
|
|
|
| 389 |
and state.get("tone", 0) >= FRAGMENT_TONES[told]):
|
| 390 |
own_fragment = OWN_FRAGMENTS[told]
|
| 391 |
|
| 392 |
+
# B2: on non-recall turns (every 3rd), quietly resurface an earlier memory
|
| 393 |
+
aware_memory = None
|
| 394 |
+
if not (do_recall and recall_memory) and state["turn"] % 3 == 0:
|
| 395 |
+
aware_memory = pick_aware_memory(state, exclude=recall_memory)
|
| 396 |
+
|
| 397 |
lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
|
| 398 |
state["msg_lengths"] = lengths[-5:] # keep the last 5
|
| 399 |
|
|
|
|
| 403 |
ask_for_memory=(state["turn"] >= 3
|
| 404 |
and not state["treasure"]),
|
| 405 |
own_fragment=own_fragment,
|
| 406 |
+
style=style_signal(state["msg_lengths"]),
|
| 407 |
+
aware_memory=aware_memory)
|
| 408 |
chat_messages = [{"role": "system", "content": system}] + state["history"] + [
|
| 409 |
{"role": "user", "content": user_msg}
|
| 410 |
]
|
|
|
|
| 429 |
if own_fragment and not turn_failed:
|
| 430 |
state["fragments_told"] = told + 1
|
| 431 |
|
| 432 |
+
if aware_memory and not turn_failed:
|
| 433 |
+
state["last_aware_memory"] = aware_memory
|
| 434 |
+
|
| 435 |
state["history"].append({"role": "user", "content": user_msg})
|
| 436 |
state["history"].append({"role": "assistant", "content": reply})
|
| 437 |
state["turn"] += 1
|
character.py
CHANGED
|
@@ -103,7 +103,8 @@ def build_system_prompt(affinity: int, treasure: list[str],
|
|
| 103 |
tone: int = 0, wounds: list[str] | None = None,
|
| 104 |
ask_for_memory: bool = False,
|
| 105 |
own_fragment: str | None = None,
|
| 106 |
-
style: str | None = None
|
|
|
|
| 107 |
tier = get_tier(affinity)
|
| 108 |
persona = _PERSONA[tier]
|
| 109 |
|
|
@@ -146,4 +147,7 @@ def build_system_prompt(affinity: int, treasure: list[str],
|
|
| 146 |
"'...no. that was yours. wasn't it?' Do this naturally, within the flow of the conversation."
|
| 147 |
)
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
return "\n".join(parts)
|
|
|
|
| 103 |
tone: int = 0, wounds: list[str] | None = None,
|
| 104 |
ask_for_memory: bool = False,
|
| 105 |
own_fragment: str | None = None,
|
| 106 |
+
style: str | None = None,
|
| 107 |
+
aware_memory: str | None = None) -> str:
|
| 108 |
tier = get_tier(affinity)
|
| 109 |
persona = _PERSONA[tier]
|
| 110 |
|
|
|
|
| 147 |
"'...no. that was yours. wasn't it?' Do this naturally, within the flow of the conversation."
|
| 148 |
)
|
| 149 |
|
| 150 |
+
if aware_memory:
|
| 151 |
+
parts.append(f"Earlier the visitor told you: \"{aware_memory}\". You may bring it up unprompted, gently, as if it has been on your mind.")
|
| 152 |
+
|
| 153 |
return "\n".join(parts)
|
memory.py
CHANGED
|
@@ -69,6 +69,17 @@ def apply_update(state: dict, raw_json: str, reply: str = "") -> dict:
|
|
| 69 |
return state
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def should_recall(state: dict) -> tuple[bool, str | None]:
|
| 73 |
if state["affinity"] < 45:
|
| 74 |
return False, None
|
|
|
|
| 69 |
return state
|
| 70 |
|
| 71 |
|
| 72 |
+
def pick_aware_memory(state: dict, exclude: str | None) -> str | None:
|
| 73 |
+
"""An earlier memory Hollow may quietly resurface this turn: unclaimed, not
|
| 74 |
+
the one being recalled (`exclude`), not the one it just resurfaced. Returns
|
| 75 |
+
the newest eligible memory, or None if none fit."""
|
| 76 |
+
claimed = set(state.get("claimed", []))
|
| 77 |
+
last = state.get("last_aware_memory")
|
| 78 |
+
pool = [m for m in state.get("treasure", [])
|
| 79 |
+
if m not in claimed and m != exclude and m != last]
|
| 80 |
+
return pool[-1] if pool else None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def should_recall(state: dict) -> tuple[bool, str | None]:
|
| 84 |
if state["affinity"] < 45:
|
| 85 |
return False, None
|
tests/test_memory.py
CHANGED
|
@@ -361,3 +361,18 @@ class TestStyleSignal:
|
|
| 361 |
from memory import style_signal
|
| 362 |
assert style_signal([]) is None
|
| 363 |
assert style_signal([10, 12, 15]) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
from memory import style_signal
|
| 362 |
assert style_signal([]) is None
|
| 363 |
assert style_signal([10, 12, 15]) is None
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
class TestPickAwareMemory:
|
| 367 |
+
def test_picks_an_unclaimed_memory_not_the_recall_one(self):
|
| 368 |
+
from memory import pick_aware_memory
|
| 369 |
+
s = {"treasure": ["a", "b", "c"], "claimed": ["a"], "last_aware_memory": None}
|
| 370 |
+
assert pick_aware_memory(s, exclude="b") == "c" # not claimed(a), not excluded(b)
|
| 371 |
+
def test_avoids_repeating_the_last_one(self):
|
| 372 |
+
from memory import pick_aware_memory
|
| 373 |
+
s = {"treasure": ["a", "b"], "claimed": [], "last_aware_memory": "b"}
|
| 374 |
+
assert pick_aware_memory(s, exclude=None) == "a"
|
| 375 |
+
def test_none_when_nothing_eligible(self):
|
| 376 |
+
from memory import pick_aware_memory
|
| 377 |
+
s = {"treasure": ["a"], "claimed": ["a"], "last_aware_memory": None}
|
| 378 |
+
assert pick_aware_memory(s, exclude=None) is None
|