Pabloler21 Claude Fable 5 commited on
Commit
a2e44d7
·
1 Parent(s): e22b6d8

feat: B2 — Hollow resurfaces your memories unprompted

Browse files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +14 -2
  2. character.py +5 -1
  3. memory.py +11 -0
  4. 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) -> str:
 
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