unique-user-memory 2026.32.0.dev3__tar.gz → 2026.32.0.dev4__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.32.0.dev3
3
+ Version: 2026.32.0.dev4
4
4
  Summary:
5
5
  Author: Fabian Schläpfer
6
6
  Author-email: Fabian Schläpfer <fabian@unique.ch>
@@ -24,6 +24,7 @@ The package provides:
24
24
 
25
25
  - `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
26
26
  - `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below). Returns a `UserMemoryState` with the profile text and scope id.
27
+ - `profile_body(...)` - strips the YAML frontmatter and returns only the Markdown body. Use it whenever the profile is shown to a model; the frontmatter is bookkeeping for consolidation.
27
28
  - `UserMemoryMessageLogger` - emits chat Steps (MessageLogs) for load and update, including typed `UserMemory` detail entries the chat frontend renders as a badge that opens Settings → Context Memory. Frontends that do not know the entry type render nothing, so the entries are safe to emit in any deploy order.
28
29
  - `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
29
30
 
@@ -34,7 +35,7 @@ The memory file is intentionally small and structured. It is rewritten as a full
34
35
  1. The orchestrator enables memory when `space.allow_user_memory` is true.
35
36
  2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
36
37
  3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
37
- 4. If memory was loaded, its text is passed into the agent context for the current turn.
38
+ 4. If memory was loaded, `profile_body(...)` of its text is passed into the agent context for the current turn — the prompt only gets the Markdown body, while the postprocessor keeps the full file because it needs the frontmatter to carry `turn_count` forward.
38
39
  5. `UserMemoryPostprocessor` runs after the assistant response.
39
40
  6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
40
41
  7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
@@ -113,7 +114,7 @@ Typical orchestration code loads memory before the agent loop and registers the
113
114
 
114
115
  ```python
115
116
  from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
116
- from unique_user_memory.user_memory import load_user_memory
117
+ from unique_user_memory.user_memory import load_user_memory, profile_body
117
118
  from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
118
119
  from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
119
120
 
@@ -157,7 +158,9 @@ finally:
157
158
 
158
159
  if load_succeeded and user_memory_state is not None:
159
160
  await memory_message_step_logger.log_loading_complete(with_settings_entry=True)
160
- user_memory_text = user_memory_state.text
161
+ # The postprocessor keeps the full file (it needs the frontmatter to
162
+ # carry turn_count forward); the prompt only gets the Markdown body.
163
+ user_memory_text = profile_body(user_memory_state.text)
161
164
  postprocessor_manager.add_postprocessor(
162
165
  UserMemoryPostprocessor(
163
166
  config=user_memory_config,
@@ -10,6 +10,7 @@ The package provides:
10
10
 
11
11
  - `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
12
12
  - `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below). Returns a `UserMemoryState` with the profile text and scope id.
13
+ - `profile_body(...)` - strips the YAML frontmatter and returns only the Markdown body. Use it whenever the profile is shown to a model; the frontmatter is bookkeeping for consolidation.
13
14
  - `UserMemoryMessageLogger` - emits chat Steps (MessageLogs) for load and update, including typed `UserMemory` detail entries the chat frontend renders as a badge that opens Settings → Context Memory. Frontends that do not know the entry type render nothing, so the entries are safe to emit in any deploy order.
14
15
  - `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
15
16
 
@@ -20,7 +21,7 @@ The memory file is intentionally small and structured. It is rewritten as a full
20
21
  1. The orchestrator enables memory when `space.allow_user_memory` is true.
21
22
  2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
22
23
  3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
23
- 4. If memory was loaded, its text is passed into the agent context for the current turn.
24
+ 4. If memory was loaded, `profile_body(...)` of its text is passed into the agent context for the current turn — the prompt only gets the Markdown body, while the postprocessor keeps the full file because it needs the frontmatter to carry `turn_count` forward.
24
25
  5. `UserMemoryPostprocessor` runs after the assistant response.
25
26
  6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
26
27
  7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
@@ -99,7 +100,7 @@ Typical orchestration code loads memory before the agent loop and registers the
99
100
 
100
101
  ```python
101
102
  from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
102
- from unique_user_memory.user_memory import load_user_memory
103
+ from unique_user_memory.user_memory import load_user_memory, profile_body
103
104
  from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
104
105
  from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
105
106
 
@@ -143,7 +144,9 @@ finally:
143
144
 
144
145
  if load_succeeded and user_memory_state is not None:
145
146
  await memory_message_step_logger.log_loading_complete(with_settings_entry=True)
146
- user_memory_text = user_memory_state.text
147
+ # The postprocessor keeps the full file (it needs the frontmatter to
148
+ # carry turn_count forward); the prompt only gets the Markdown body.
149
+ user_memory_text = profile_body(user_memory_state.text)
147
150
  postprocessor_manager.add_postprocessor(
148
151
  UserMemoryPostprocessor(
149
152
  config=user_memory_config,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_user_memory"
3
- version = "2026.32.0.dev3"
3
+ version = "2026.32.0.dev4"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Fabian Schläpfer", email = "fabian@unique.ch" },
@@ -21,6 +21,7 @@ from unique_user_memory.user_memory import (
21
21
  enforce_token_cap,
22
22
  ensure_user_memory_folder,
23
23
  fit_user_memory,
24
+ profile_body,
24
25
  should_consolidate_memory,
25
26
  upload_user_memory,
26
27
  )
@@ -46,6 +47,37 @@ def test_memory_profile_keeps_follow_up_tasks_but_excludes_open_questions() -> N
46
47
  assert "Concrete future tasks the user intends to complete" in gate_prompt
47
48
 
48
49
 
50
+ def test_profile_body_strips_frontmatter() -> None:
51
+ # The orchestrator renders this into the system prompt, where the
52
+ # bookkeeping fields are noise for the model.
53
+ content = (
54
+ "---\n"
55
+ "user_id: 233737684428787846\n"
56
+ "strategy: codex\n"
57
+ "schema_version: 1\n"
58
+ "last_updated: 2026-07-27T10:20:00+00:00\n"
59
+ "turn_count: 83\n"
60
+ "---\n\n"
61
+ "# User Memory\n\n## Identity\n- Andreas\n"
62
+ )
63
+
64
+ body = profile_body(content)
65
+
66
+ assert body == "# User Memory\n\n## Identity\n- Andreas"
67
+ for field in ("user_id:", "strategy:", "schema_version:", "turn_count:"):
68
+ assert field not in body
69
+
70
+
71
+ def test_profile_body_leaves_frontmatterless_profile_untouched() -> None:
72
+ content = "# User Memory\n\n## Identity\n- Andreas"
73
+
74
+ assert profile_body(content) == content
75
+
76
+
77
+ def test_profile_body_returns_empty_for_frontmatter_only_profile() -> None:
78
+ assert profile_body("---\nuser_id: 42\nturn_count: 0\n---\n") == ""
79
+
80
+
49
81
  def test_enforce_token_cap_truncates_long_content() -> None:
50
82
  content = "\n\n".join(f"paragraph {index} " + "word " * 40 for index in range(50))
51
83
 
@@ -63,7 +63,14 @@ _FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
63
63
  _TURN_COUNT_RE = re.compile(r"^turn_count:\s*(\d+)\s*$", re.MULTILINE)
64
64
 
65
65
 
66
- def _profile_body(content: str) -> str:
66
+ def profile_body(content: str) -> str:
67
+ """Return the profile without its YAML frontmatter.
68
+
69
+ The frontmatter is bookkeeping for the consolidation pass (turn count,
70
+ schema version, timestamps); consumers that show the profile to a model --
71
+ consolidation prompts and the orchestrator system prompt -- only want the
72
+ Markdown body.
73
+ """
67
74
  return _FRONTMATTER_RE.sub("", content, count=1).strip()
68
75
 
69
76
 
@@ -195,7 +202,7 @@ async def condense_user_memory(
195
202
  condensed profile, or ``None`` when the call fails or the output does
196
203
  not look like a profile (the caller then falls back to a hard cut).
197
204
  """
198
- body = _profile_body(content)
205
+ body = profile_body(content)
199
206
  current_tokens = count_tokens(content=body, language_model=language_model)
200
207
  target_tokens = max(1, int(max_tokens * _CONDENSE_TARGET_RATIO))
201
208
 
@@ -265,7 +272,7 @@ async def condense_user_memory(
265
272
  )
266
273
  return None
267
274
 
268
- candidate = _profile_body(_strip_code_fences(raw))
275
+ candidate = profile_body(_strip_code_fences(raw))
269
276
  if not _is_well_formed_profile(candidate):
270
277
  logger.warning(
271
278
  "[user-memory] condense output did not look like a profile (%d chars)",
@@ -826,7 +833,7 @@ async def _rewrite_user_memory(
826
833
  ),
827
834
  LanguageModelUserMessage(
828
835
  content=consolidation_user_prompt(
829
- existing_memory=_profile_body(safe_current),
836
+ existing_memory=profile_body(safe_current),
830
837
  user_message=_sanitize_for_xml_context(user_message or ""),
831
838
  assistant_message=_sanitize_for_xml_context(
832
839
  assistant_message or ""
@@ -883,7 +890,7 @@ async def _rewrite_user_memory(
883
890
  logger.info("[user-memory] consolidation NOOP - keeping existing memory")
884
891
  return safe_current
885
892
 
886
- candidate_body = _profile_body(_strip_code_fences(raw))
893
+ candidate_body = profile_body(_strip_code_fences(raw))
887
894
  if not _is_well_formed_profile(candidate_body):
888
895
  logger.warning(
889
896
  "[user-memory] LLM output did not look like a profile (%d chars)",
@@ -891,7 +898,7 @@ async def _rewrite_user_memory(
891
898
  )
892
899
  return safe_current
893
900
 
894
- if safe_current and candidate_body == _profile_body(safe_current):
901
+ if safe_current and candidate_body == profile_body(safe_current):
895
902
  logger.debug("[user-memory] memory body unchanged - skipping update")
896
903
  return safe_current
897
904