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}