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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-user-memory
3
- Version: 2026.36.0.dev2
3
+ Version: 2026.36.0.dev3
4
4
  Summary:
5
5
  Author: Fabian Schläpfer
6
6
  Author-email: Fabian Schläpfer <fabian@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_user_memory"
3
- version = "2026.36.0.dev2"
3
+ version = "2026.36.0.dev3"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Fabian Schläpfer", email = "fabian@unique.ch" },
@@ -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 = "# User Memory\n\n## Identity\n- concise summary of the user"
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 = "# User Memory\n\n## Identity\n- concise summary"
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" + condensed
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
300
- condensed = "# User Memory\n\n## Identity\n- Concise"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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" + rewritten
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = profile_body(_strip_code_fences(raw))
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
- return content.startswith("# User Memory") and "## Identity" in content
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
- `# User Memory`. Do NOT emit a diff. Do NOT wrap the output in
64
- ``` fences. Do NOT add commentary before or after the body.
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
- `# User Memory`. Do NOT emit a diff, or commentary,
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