Spaces:
Running
Running
File size: 8,973 Bytes
f0347b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | """Step 6 of the living loop: curated memory (F2, v4).
Durable, consolidated knowledge - NOT a transcript. The raw chat transcript
itself is owned entirely by `gr.Chatbot`'s session history (passed into
`engine.loop.build_messages()` for multi-turn coherence); this module never
stores a copy of it. Since Daimon runs 100% on a local model, the extra
curation call is free, so both files are rewritten by `curate_memory()`
after every turn:
- `memory.md` - cross-session long-term memory: `## User profile`,
`## Stable preferences and behavioral patterns`,
`## Notable interactions`.
- `memory/<date>.md` - this session's consolidated summary: YAML
frontmatter (`date`, `session_id`) + `## episodic`,
`## user_preferences`, `## procedural`, `## autobiographical`.
"""
from __future__ import annotations
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from engine.spec_bridge import PERSONAS_DIR # noqa: E402
# Guards against a small model echoing the prompt/turn back as "content":
# if any of these show up in the output, treat it as a malformed reply and
# leave the memory file untouched rather than corrupt it.
_BAD_MARKERS = ("new turn:", "current sections:", "your reply:", "user:", "daimon:")
# Both curation prompts ask the model for ONLY the bullet sections (not the
# header/frontmatter, which are deterministic and applied by Python below) -
# a 1B model is unreliable at reproducing boilerplate verbatim, so we never
# ask it to.
_LONG_TERM_SECTIONS = ("User profile", "Stable preferences and behavioral patterns", "Notable interactions")
_EPISODIC_SECTIONS = ("episodic", "user_preferences", "procedural", "autobiographical")
_LONG_TERM_SYSTEM_PROMPT = (
"You maintain Daimon's long-term memory: durable, cross-session "
"knowledge about the USER and this relationship - NOT a transcript.\n\n"
"Output ONLY these 3 sections, each a bullet list of at most 4 bullets, "
"each under 15 words:\n\n"
"## User profile\n- ...\n\n"
"## Stable preferences and behavioral patterns\n- ...\n\n"
"## Notable interactions\n- ...\n\n"
"You will see the CURRENT sections and ONE new turn. If the turn reveals "
"a new durable fact (about the user, their preferences, or a notable "
"refusal/boundary moment), output the updated sections (same 3, in "
"order, each trimmed to at most 4 bullets - drop the oldest/least "
"useful first). If nothing durable changed, reply with exactly: "
"NO_CHANGE\n\n"
"Example reply:\n"
"## User profile\n- User is named Ana, a veterinarian.\n\n"
"## Stable preferences and behavioral patterns\n- Prefers short, direct answers.\n\n"
"## Notable interactions\n- (none yet)"
)
_EPISODIC_SYSTEM_PROMPT = (
"You maintain Daimon's consolidated session summary for today - a "
"narrative summary of THIS conversation so far, NOT a transcript.\n\n"
"Output ONLY these 4 sections, each a bullet list of at most 5 bullets, "
"each under 20 words:\n\n"
"## episodic\n- what happened this session (events, topics, outcomes)\n\n"
"## user_preferences\n- preferences the user expressed this session\n\n"
"## procedural\n- how Daimon should act/respond going forward, learned this session\n\n"
"## autobiographical\n- what Daimon itself did/decided/felt this session\n\n"
"You will see the CURRENT sections and ONE new turn. Output the updated "
"sections (same 4, in order, each trimmed to at most 5 bullets - drop "
"the oldest/least useful first). If the turn adds nothing to any "
"section, reply with exactly: NO_CHANGE"
)
_FENCE_RE = re.compile(r"^```\w*\s*$", re.MULTILINE)
# A small model occasionally echoes a section header back as if it were a
# bullet (e.g. "- User profile" inside "## Notable interactions") - drop
# bullets that are just one of our own header names.
_KNOWN_HEADERS = {h.lower() for h in _LONG_TERM_SECTIONS + _EPISODIC_SECTIONS}
def _extract_section_bullets(text: str, header: str, limit: int) -> list[str]:
"""Return up to `limit` '- ' bullet lines found under `## {header}` in
`text` (case-sensitive header match, stops at the next `## ` or EOF)."""
pattern = re.compile(rf"^## {re.escape(header)}\s*\n(.*?)(?=\n## |\Z)", re.DOTALL | re.MULTILINE)
m = pattern.search(text)
if not m:
return []
bullets = [ln.strip() for ln in m.group(1).splitlines() if ln.strip().startswith("- ")]
bullets = [b for b in bullets if b.lower() not in ("- (none yet)", "- ...")]
bullets = [b for b in bullets if b[2:].strip().lower() not in _KNOWN_HEADERS]
return bullets[:limit]
def _memory_dir(slug: str) -> Path:
path = PERSONAS_DIR / slug / "memory"
path.mkdir(parents=True, exist_ok=True)
return path
def _memory_md_path(slug: str) -> Path:
return PERSONAS_DIR / slug / "memory.md"
def _today() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _episodic_path(slug: str) -> Path:
return _memory_dir(slug) / f"{_today()}.md"
def _rebuild(sections: tuple[str, ...], preamble: str, current: str, model_out: str, limit: int) -> tuple[str, bool]:
"""Deterministically reassemble a curated memory file from `preamble`
(header/frontmatter, never written by the model) plus, for each
section, whichever bullet list is non-empty between the model's
proposal and the current file (the model's proposal wins; if it left a
section out entirely, the old bullets for that section are kept)."""
changed = False
lines = [preamble.rstrip(), ""]
for header in sections:
old = _extract_section_bullets(current, header, limit)
new = _extract_section_bullets(model_out, header, limit)
bullets = new or old
if bullets != old:
changed = True
lines.append(f"## {header}")
lines.extend(bullets if bullets else ["- (none yet)"])
lines.append("")
return "\n".join(lines).rstrip() + "\n", changed
def _curate_file(
path: Path,
*,
system_prompt: str,
sections: tuple[str, ...],
limit: int,
preamble: str,
user_message: str,
reply: str,
max_tokens: int,
) -> bool:
"""Show the model the CURRENT bullet sections of `path` plus one new
turn, and let it propose updated sections (not the whole file - the
header/frontmatter is reapplied deterministically by `_rebuild`).
Returns True if `path` was rewritten. Best-effort: any model/parsing
failure or no-op proposal leaves `path` untouched."""
from model.client import chat # local import: keep memory.py importable without a model server
current = path.read_text(encoding="utf-8") if path.exists() else ""
current_sections = "\n\n".join(
f"## {header}\n" + "\n".join(_extract_section_bullets(current, header, limit) or ["- (none yet)"])
for header in sections
)
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": (
f"Current sections:\n{current_sections}\n\n"
f"New turn:\nUser: {user_message}\nDaimon: {reply}\n\n"
"Your reply:"
),
},
]
try:
out = chat(messages, modality="text", max_tokens=max_tokens, temperature=0.1, enable_thinking=False)
except Exception:
return False
out = _FENCE_RE.sub("", (out or "")).strip()
if not out or out.upper().startswith("NO_CHANGE"):
return False
if any(marker in out.lower() for marker in _BAD_MARKERS):
return False # model echoed the prompt back - don't corrupt the file
new_text, changed = _rebuild(sections, preamble, current, out, limit)
if not changed:
return False
path.write_text(new_text, encoding="utf-8")
return True
def curate_memory(slug: str, user_message: str, reply: str) -> dict[str, bool]:
"""Let the local model update both curated-memory files for the new
turn: `memory.md` (cross-session) and `memory/<date>.md` (today's
consolidated summary). Returns which files actually changed."""
long_term_changed = _curate_file(
_memory_md_path(slug),
system_prompt=_LONG_TERM_SYSTEM_PROMPT,
sections=_LONG_TERM_SECTIONS,
limit=4,
preamble="# Daimon - long-term memory",
user_message=user_message,
reply=reply,
max_tokens=200,
)
episodic_changed = _curate_file(
_episodic_path(slug),
system_prompt=_EPISODIC_SYSTEM_PROMPT,
sections=_EPISODIC_SECTIONS,
limit=5,
preamble=f"---\ndate: {_today()}\nsession_id: {slug}-{_today()}\n---",
user_message=user_message,
reply=reply,
max_tokens=300,
)
return {"memory.md": long_term_changed, f"memory/{_today()}.md": episodic_changed}
|