unique-user-memory 2026.36.0.dev2__tar.gz → 2026.36.0.dev3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/PKG-INFO +1 -1
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/pyproject.toml +1 -1
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/tests/test_user_memory.py +85 -11
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/user_memory.py +14 -3
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/user_memory_prompts.py +5 -6
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/README.md +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/config.py +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/user_memory_message_log.py +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/user_memory_postprocessor.py +0 -0
|
@@ -26,6 +26,7 @@ from unique_user_memory.config import UserMemoryConfig
|
|
|
26
26
|
from unique_user_memory.user_memory import (
|
|
27
27
|
UserMemoryState,
|
|
28
28
|
_gate_max_tokens,
|
|
29
|
+
_is_well_formed_profile,
|
|
29
30
|
_sanitize_for_xml_context,
|
|
30
31
|
condense_user_memory,
|
|
31
32
|
consolidate_user_memory,
|
|
@@ -41,6 +42,7 @@ from unique_user_memory.user_memory import (
|
|
|
41
42
|
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
42
43
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
43
44
|
from unique_user_memory.user_memory_prompts import (
|
|
45
|
+
condensation_system_prompt,
|
|
44
46
|
consolidation_system_prompt,
|
|
45
47
|
empty_profile,
|
|
46
48
|
memory_gate_system_prompt,
|
|
@@ -49,6 +51,23 @@ from unique_user_memory.user_memory_prompts import (
|
|
|
49
51
|
_TEST_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL)
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _complete_profile_body(
|
|
55
|
+
identity: str = "- Prefers concise answers",
|
|
56
|
+
*,
|
|
57
|
+
legacy_title: bool = False,
|
|
58
|
+
) -> str:
|
|
59
|
+
sections = (
|
|
60
|
+
f"## Identity\n{identity}",
|
|
61
|
+
"## Communication Preferences\n_(empty)_",
|
|
62
|
+
"## Work Context\n_(empty)_",
|
|
63
|
+
"## Skills & Expertise\n_(empty)_",
|
|
64
|
+
"## Follow-ups\n_(empty)_",
|
|
65
|
+
"## Recent Topics\n_(empty)_",
|
|
66
|
+
)
|
|
67
|
+
body = "\n\n".join(sections)
|
|
68
|
+
return f"# User Memory\n\n{body}" if legacy_title else body
|
|
69
|
+
|
|
70
|
+
|
|
52
71
|
def _chat_event() -> ChatEvent:
|
|
53
72
|
return ChatEvent(
|
|
54
73
|
id="event_1",
|
|
@@ -111,6 +130,59 @@ def test_recent_topics_is_last_section_and_deduplicated() -> None:
|
|
|
111
130
|
assert "do NOT also log that same information" in consolidation_prompt
|
|
112
131
|
|
|
113
132
|
|
|
133
|
+
@pytest.mark.ai
|
|
134
|
+
def test_generated_profile_templates_start_with_identity_without_document_title() -> (
|
|
135
|
+
None
|
|
136
|
+
):
|
|
137
|
+
"""Purpose: Verify new profile output omits the obsolete document-level title.
|
|
138
|
+
Why this matters: Persistent memory should expose only its meaningful H2 sections.
|
|
139
|
+
Setup summary: Render empty and LLM prompts, then assert the required start format.
|
|
140
|
+
"""
|
|
141
|
+
profile = empty_profile("user_1")
|
|
142
|
+
prompts = (
|
|
143
|
+
consolidation_system_prompt(2000),
|
|
144
|
+
condensation_system_prompt(
|
|
145
|
+
max_tokens=2000,
|
|
146
|
+
current_tokens=2200,
|
|
147
|
+
target_tokens=1800,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert profile_body(profile).startswith("## Identity")
|
|
152
|
+
assert "# User Memory" not in profile
|
|
153
|
+
assert all("starting with\n`## Identity`" in prompt for prompt in prompts)
|
|
154
|
+
assert all(
|
|
155
|
+
"Do NOT add a document-level `# User Memory` title" in prompt
|
|
156
|
+
for prompt in prompts
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.ai
|
|
161
|
+
@pytest.mark.parametrize("legacy_title", [False, True])
|
|
162
|
+
def test_well_formed_profile_accepts_current_and_legacy_complete_profiles(
|
|
163
|
+
legacy_title: bool,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Purpose: Verify validation supports titleless output and stored legacy profiles.
|
|
166
|
+
Why this matters: New rewrites must migrate format without breaking old memories.
|
|
167
|
+
Setup summary: Validate complete profiles with and without the former H1 title.
|
|
168
|
+
"""
|
|
169
|
+
assert _is_well_formed_profile(_complete_profile_body(legacy_title=legacy_title))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.ai
|
|
173
|
+
def test_well_formed_profile_rejects_missing_required_section() -> None:
|
|
174
|
+
"""Purpose: Verify rewritten profiles retain every required H2 section.
|
|
175
|
+
Why this matters: A rewrite must not silently erase a category of user memory.
|
|
176
|
+
Setup summary: Remove Work Context from a complete profile and validate rejection.
|
|
177
|
+
"""
|
|
178
|
+
profile = _complete_profile_body().replace(
|
|
179
|
+
"## Work Context\n_(empty)_\n\n",
|
|
180
|
+
"",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert not _is_well_formed_profile(profile)
|
|
184
|
+
|
|
185
|
+
|
|
114
186
|
def test_profile_body_strips_frontmatter() -> None:
|
|
115
187
|
# The orchestrator renders this into the system prompt, where the
|
|
116
188
|
# bookkeeping fields are noise for the model.
|
|
@@ -190,7 +262,7 @@ async def test_fit_user_memory_condenses_before_hard_cut(
|
|
|
190
262
|
f"- fact number {index} that is fairly wordy about the user"
|
|
191
263
|
for index in range(400)
|
|
192
264
|
)
|
|
193
|
-
condensed = "
|
|
265
|
+
condensed = _complete_profile_body("- concise summary of the user")
|
|
194
266
|
condense = AsyncMock(return_value=condensed)
|
|
195
267
|
monkeypatch.setattr(
|
|
196
268
|
"unique_user_memory.user_memory.condense_user_memory",
|
|
@@ -264,10 +336,11 @@ async def test_condense_user_memory_accepts_frontmatter_output(
|
|
|
264
336
|
monkeypatch: pytest.MonkeyPatch,
|
|
265
337
|
) -> None:
|
|
266
338
|
"""Accept legacy LLM output while returning only the condensed profile body."""
|
|
267
|
-
condensed = "
|
|
339
|
+
condensed = _complete_profile_body("- concise summary")
|
|
268
340
|
response = MagicMock()
|
|
269
341
|
response.choices[0].message.content = (
|
|
270
|
-
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
342
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
343
|
+
+ _complete_profile_body("- concise summary", legacy_title=True)
|
|
271
344
|
)
|
|
272
345
|
llm_service = MagicMock()
|
|
273
346
|
llm_service.complete_async = AsyncMock(return_value=response)
|
|
@@ -296,8 +369,8 @@ async def test_user_memory_llm_paths_forward_event_attribution(
|
|
|
296
369
|
Why this matters: Every user-memory ModelUsage row must link to its chat.
|
|
297
370
|
Setup summary: Exercise all shared service paths and inspect toolkit calls.
|
|
298
371
|
"""
|
|
299
|
-
rewritten =
|
|
300
|
-
condensed = "
|
|
372
|
+
rewritten = _complete_profile_body()
|
|
373
|
+
condensed = _complete_profile_body("- Concise")
|
|
301
374
|
complete_async = AsyncMock(
|
|
302
375
|
side_effect=[
|
|
303
376
|
_completion_response("UPDATE"),
|
|
@@ -462,7 +535,7 @@ async def test_consolidate_user_memory_runs_full_rewrite_when_gate_update(
|
|
|
462
535
|
monkeypatch: pytest.MonkeyPatch,
|
|
463
536
|
) -> None:
|
|
464
537
|
current = empty_profile("user_1")
|
|
465
|
-
rewritten =
|
|
538
|
+
rewritten = _complete_profile_body()
|
|
466
539
|
response = MagicMock()
|
|
467
540
|
response.choices[0].message.content = rewritten
|
|
468
541
|
llm_service = MagicMock()
|
|
@@ -498,7 +571,7 @@ async def test_consolidate_user_memory_runs_full_rewrite_when_gate_update(
|
|
|
498
571
|
async def test_consolidate_user_memory_adds_frontmatter_to_llm_body(
|
|
499
572
|
monkeypatch: pytest.MonkeyPatch,
|
|
500
573
|
) -> None:
|
|
501
|
-
rewritten =
|
|
574
|
+
rewritten = _complete_profile_body()
|
|
502
575
|
response = MagicMock()
|
|
503
576
|
response.choices[0].message.content = rewritten
|
|
504
577
|
llm_service = MagicMock()
|
|
@@ -534,10 +607,11 @@ async def test_consolidate_user_memory_replaces_llm_frontmatter(
|
|
|
534
607
|
monkeypatch: pytest.MonkeyPatch,
|
|
535
608
|
) -> None:
|
|
536
609
|
"""Strip untrusted legacy metadata before assembling the updated profile."""
|
|
537
|
-
rewritten =
|
|
610
|
+
rewritten = _complete_profile_body()
|
|
538
611
|
response = MagicMock()
|
|
539
612
|
response.choices[0].message.content = (
|
|
540
|
-
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
613
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
614
|
+
+ _complete_profile_body(legacy_title=True)
|
|
541
615
|
)
|
|
542
616
|
llm_service = MagicMock()
|
|
543
617
|
llm_service.complete_async = AsyncMock(return_value=response)
|
|
@@ -572,7 +646,7 @@ async def test_consolidate_user_memory_skips_gate_when_disabled(
|
|
|
572
646
|
monkeypatch: pytest.MonkeyPatch,
|
|
573
647
|
) -> None:
|
|
574
648
|
current = empty_profile("user_1")
|
|
575
|
-
rewritten =
|
|
649
|
+
rewritten = _complete_profile_body()
|
|
576
650
|
response = MagicMock()
|
|
577
651
|
response.choices[0].message.content = rewritten
|
|
578
652
|
llm_service = MagicMock()
|
|
@@ -729,7 +803,7 @@ async def test_should_consolidate_memory_falls_back_to_true_on_error(
|
|
|
729
803
|
async def test_consolidate_user_memory_invokes_update_callbacks_on_rewrite(
|
|
730
804
|
monkeypatch: pytest.MonkeyPatch,
|
|
731
805
|
) -> None:
|
|
732
|
-
rewritten =
|
|
806
|
+
rewritten = _complete_profile_body()
|
|
733
807
|
response = MagicMock()
|
|
734
808
|
response.choices[0].message.content = rewritten
|
|
735
809
|
llm_service = MagicMock()
|
|
@@ -69,6 +69,8 @@ _TRUNCATION_MARKER = "\n\n<!-- truncated to fit memory budget -->"
|
|
|
69
69
|
_DEFAULT_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_GPT_4o)
|
|
70
70
|
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
|
71
71
|
_TURN_COUNT_RE = re.compile(r"^turn_count:\s*(\d+)\s*$", re.MULTILINE)
|
|
72
|
+
_LEGACY_PROFILE_TITLE_RE = re.compile(r"^# User Memory[ \t]*(?:\n+|$)")
|
|
73
|
+
_H2_HEADING_RE = re.compile(r"^## (.+?)[ \t]*$", re.MULTILINE)
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
def profile_body(content: str) -> str:
|
|
@@ -286,7 +288,7 @@ async def condense_user_memory(
|
|
|
286
288
|
)
|
|
287
289
|
return None
|
|
288
290
|
|
|
289
|
-
candidate = profile_body(_strip_code_fences(raw))
|
|
291
|
+
candidate = _without_legacy_profile_title(profile_body(_strip_code_fences(raw)))
|
|
290
292
|
if not _is_well_formed_profile(candidate):
|
|
291
293
|
logger.warning(
|
|
292
294
|
"[user-memory] condense output did not look like a profile (%d chars)",
|
|
@@ -906,7 +908,9 @@ async def _rewrite_user_memory(
|
|
|
906
908
|
logger.info("[user-memory] consolidation NOOP - keeping existing memory")
|
|
907
909
|
return safe_current
|
|
908
910
|
|
|
909
|
-
candidate_body =
|
|
911
|
+
candidate_body = _without_legacy_profile_title(
|
|
912
|
+
profile_body(_strip_code_fences(raw))
|
|
913
|
+
)
|
|
910
914
|
if not _is_well_formed_profile(candidate_body):
|
|
911
915
|
logger.warning(
|
|
912
916
|
"[user-memory] LLM output did not look like a profile (%d chars)",
|
|
@@ -947,7 +951,14 @@ def _sanitize_for_xml_context(text: str) -> str:
|
|
|
947
951
|
def _is_well_formed_profile(content: str) -> bool:
|
|
948
952
|
if not content or len(content.strip()) < 20:
|
|
949
953
|
return False
|
|
950
|
-
|
|
954
|
+
body = _without_legacy_profile_title(content.strip())
|
|
955
|
+
headings = tuple(_H2_HEADING_RE.findall(body))
|
|
956
|
+
return body.startswith("## Identity") and headings == SECTION_HEADINGS
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _without_legacy_profile_title(content: str) -> str:
|
|
960
|
+
"""Remove the former document title while preserving the H2 profile body."""
|
|
961
|
+
return _LEGACY_PROFILE_TITLE_RE.sub("", content.strip(), count=1).lstrip()
|
|
951
962
|
|
|
952
963
|
|
|
953
964
|
def _strip_code_fences(text: str) -> str:
|
|
@@ -19,8 +19,6 @@ last_updated: {{ timestamp }}
|
|
|
19
19
|
turn_count: 0
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
# User Memory
|
|
23
|
-
|
|
24
22
|
{% for heading in section_headings -%}
|
|
25
23
|
## {{ heading }}
|
|
26
24
|
_(empty)_
|
|
@@ -60,8 +58,9 @@ You receive two XML blocks:
|
|
|
60
58
|
# Output
|
|
61
59
|
|
|
62
60
|
Return the complete, rewritten Markdown profile body, starting with
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
`## Identity`. Do NOT add a document-level `# User Memory` title. Do NOT
|
|
62
|
+
emit a diff. Do NOT wrap the output in ``` fences. Do NOT add commentary
|
|
63
|
+
before or after the body.
|
|
65
64
|
|
|
66
65
|
The body MUST contain exactly these section headings, in this order, even
|
|
67
66
|
when a section is empty (use the literal string `_(empty)_` as a placeholder):
|
|
@@ -256,8 +255,8 @@ fact about the user. This is lossy compression, not deletion of meaning.
|
|
|
256
255
|
# Output
|
|
257
256
|
|
|
258
257
|
Return the complete rewritten profile body, starting with
|
|
259
|
-
|
|
260
|
-
and do NOT wrap the output in ``` fences.
|
|
258
|
+
`## Identity`. Do NOT add a document-level `# User Memory` title. Do NOT
|
|
259
|
+
emit a diff or commentary, and do NOT wrap the output in ``` fences.
|
|
261
260
|
"""
|
|
262
261
|
|
|
263
262
|
|
|
File without changes
|
|
File without changes
|
{unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev3}/unique_user_memory/config.py
RENAMED
|
File without changes
|
|
File without changes
|