unique-user-memory 2026.28.0.dev4__tar.gz → 2026.28.0.dev6__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,3 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: unique-user-memory
3
+ Version: 2026.28.0.dev6
4
+ Summary:
5
+ Author: Fabian Schläpfer
6
+ Author-email: Fabian Schläpfer <fabian@unique.ch>
7
+ License: Proprietary
8
+ Requires-Dist: jinja2>=3.1.6
9
+ Requires-Dist: pydantic>=2.8.2
10
+ Requires-Dist: unique-sdk>=2026.28.0.dev15,<2026.28.0rc0
11
+ Requires-Dist: unique-toolkit>=2026.28.0.dev14,<2026.28.0rc0
12
+ Requires-Python: >=3.12, <4
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # Unique User Memory
2
16
 
3
17
  Persistent per-user memory for Unique AI agents.
@@ -9,7 +23,7 @@ Persistent per-user memory for Unique AI agents.
9
23
  The package provides:
10
24
 
11
25
  - `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
12
- - `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget.
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).
13
27
  - `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
14
28
 
15
29
  The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
@@ -83,21 +97,35 @@ config = UserMemoryConfig(
83
97
 
84
98
  | Field | Default | Description |
85
99
  | --- | --- | --- |
86
- | `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn into the profile. |
100
+ | `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
101
+ | `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
87
102
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
88
103
  | `root_folder` | `user-memory` | Root KB folder that contains per-user memory folders. |
89
104
 
90
105
  ## Integration
91
106
 
92
- Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn:
107
+ Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
108
+
109
+ `load_user_memory` and `UserMemoryPostprocessor` must be given the **same** effective language model: the postprocessor consolidates memory with either the orchestrator model or the configured one depending on `use_orchestrator_language_model`, and load-time token capping must use that same model so the loaded baseline is tokenized the way consolidation expects. Resolve the effective model once and pass it to both:
93
110
 
94
111
  ```python
95
112
  from unique_user_memory.user_memory import load_user_memory
96
113
  from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
97
114
 
115
+ user_memory_config = config.agent.services.user_memory_config
116
+
117
+ # Resolve the effective model once and reuse it for load-time capping and
118
+ # consolidation so both use the same tokenizer.
119
+ memory_language_model = (
120
+ config.space.language_model
121
+ if user_memory_config.use_orchestrator_language_model
122
+ else user_memory_config.language_model
123
+ )
124
+
98
125
  user_memory_state = await load_user_memory(
99
126
  event=event,
100
- config=config.agent.services.user_memory_config,
127
+ config=user_memory_config,
128
+ language_model=memory_language_model,
101
129
  logger=logger,
102
130
  )
103
131
 
@@ -105,10 +133,13 @@ if user_memory_state is not None:
105
133
  user_memory_text = user_memory_state.text
106
134
  postprocessor_manager.add_postprocessor(
107
135
  UserMemoryPostprocessor(
108
- config=config.agent.services.user_memory_config,
136
+ config=user_memory_config,
137
+ language_model=memory_language_model,
109
138
  event=event,
110
139
  state=user_memory_state,
111
140
  logger=logger,
112
141
  )
113
142
  )
114
143
  ```
144
+
145
+ Note that `UserMemoryPostprocessor` re-derives the effective model internally from `use_orchestrator_language_model`, so passing `memory_language_model` (rather than the raw orchestrator model) keeps its behavior identical while ensuring `load_user_memory` caps with the matching tokenizer.
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.3
2
- Name: unique-user-memory
3
- Version: 2026.28.0.dev4
4
- Summary:
5
- Author: Fabian Schläpfer
6
- Author-email: Fabian Schläpfer <fabian@unique.ch>
7
- License: Proprietary
8
- Requires-Dist: jinja2>=3.1.6
9
- Requires-Dist: pydantic>=2.8.2
10
- Requires-Dist: unique-sdk>=2026.28.0.dev8,<2026.28.0rc0
11
- Requires-Dist: unique-toolkit>=2026.28.0.dev6,<2026.28.0rc0
12
- Requires-Python: >=3.12, <4
13
- Description-Content-Type: text/markdown
14
-
15
1
  # Unique User Memory
16
2
 
17
3
  Persistent per-user memory for Unique AI agents.
@@ -23,7 +9,7 @@ Persistent per-user memory for Unique AI agents.
23
9
  The package provides:
24
10
 
25
11
  - `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
26
- - `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget.
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).
27
13
  - `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
28
14
 
29
15
  The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
@@ -97,21 +83,35 @@ config = UserMemoryConfig(
97
83
 
98
84
  | Field | Default | Description |
99
85
  | --- | --- | --- |
100
- | `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn into the profile. |
86
+ | `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
87
+ | `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
101
88
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
102
89
  | `root_folder` | `user-memory` | Root KB folder that contains per-user memory folders. |
103
90
 
104
91
  ## Integration
105
92
 
106
- Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn:
93
+ Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
94
+
95
+ `load_user_memory` and `UserMemoryPostprocessor` must be given the **same** effective language model: the postprocessor consolidates memory with either the orchestrator model or the configured one depending on `use_orchestrator_language_model`, and load-time token capping must use that same model so the loaded baseline is tokenized the way consolidation expects. Resolve the effective model once and pass it to both:
107
96
 
108
97
  ```python
109
98
  from unique_user_memory.user_memory import load_user_memory
110
99
  from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
111
100
 
101
+ user_memory_config = config.agent.services.user_memory_config
102
+
103
+ # Resolve the effective model once and reuse it for load-time capping and
104
+ # consolidation so both use the same tokenizer.
105
+ memory_language_model = (
106
+ config.space.language_model
107
+ if user_memory_config.use_orchestrator_language_model
108
+ else user_memory_config.language_model
109
+ )
110
+
112
111
  user_memory_state = await load_user_memory(
113
112
  event=event,
114
- config=config.agent.services.user_memory_config,
113
+ config=user_memory_config,
114
+ language_model=memory_language_model,
115
115
  logger=logger,
116
116
  )
117
117
 
@@ -119,10 +119,13 @@ if user_memory_state is not None:
119
119
  user_memory_text = user_memory_state.text
120
120
  postprocessor_manager.add_postprocessor(
121
121
  UserMemoryPostprocessor(
122
- config=config.agent.services.user_memory_config,
122
+ config=user_memory_config,
123
+ language_model=memory_language_model,
123
124
  event=event,
124
125
  state=user_memory_state,
125
126
  logger=logger,
126
127
  )
127
128
  )
128
129
  ```
130
+
131
+ Note that `UserMemoryPostprocessor` re-derives the effective model internally from `use_orchestrator_language_model`, so passing `memory_language_model` (rather than the raw orchestrator model) keeps its behavior identical while ensuring `load_user_memory` caps with the matching tokenizer.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_user_memory"
3
- version = "2026.28.0.dev4"
3
+ version = "2026.28.0.dev6"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Fabian Schläpfer", email = "fabian@unique.ch" },
@@ -11,8 +11,8 @@ requires-python = ">=3.12,<4"
11
11
  dependencies = [
12
12
  "jinja2>=3.1.6",
13
13
  "pydantic>=2.8.2",
14
- "unique-sdk>=2026.28.0.dev8,<2026.28.0rc0",
15
- "unique-toolkit>=2026.28.0.dev6,<2026.28.0rc0",
14
+ "unique-sdk>=2026.28.0.dev15,<2026.28.0rc0",
15
+ "unique-toolkit>=2026.28.0.dev14,<2026.28.0rc0",
16
16
  ]
17
17
 
18
18
  [dependency-groups]
@@ -4,16 +4,29 @@ from pydantic import BaseModel, Field
4
4
  from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
5
5
  from unique_toolkit._common.validators import LMI
6
6
  from unique_toolkit.agentic.tools.config import get_configuration_dict
7
- from unique_toolkit.language_model.default_language_model import DEFAULT_GPT_4o
7
+ from unique_toolkit.language_model.default_language_model import (
8
+ DEFAULT_LANGUAGE_MODEL,
9
+ )
8
10
  from unique_toolkit.language_model.infos import LanguageModelInfo
9
11
 
10
12
 
11
13
  class UserMemoryConfig(BaseModel):
12
14
  model_config = get_configuration_dict()
13
15
 
16
+ use_orchestrator_language_model: bool = Field(
17
+ default=True,
18
+ description=(
19
+ "When true, post-turn memory consolidation uses the orchestrator's "
20
+ "language model and the configured 'language_model' is ignored. "
21
+ "When false, the configured 'language_model' is used."
22
+ ),
23
+ )
14
24
  language_model: LMI = Field(
15
- default=LanguageModelInfo.from_name(DEFAULT_GPT_4o),
16
- description="The language model used for post-turn memory consolidation.",
25
+ default=LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL),
26
+ description=(
27
+ "The language model used for post-turn memory consolidation when "
28
+ "'Use Orchestrator Language Model' is false."
29
+ ),
17
30
  )
18
31
  max_tokens: int = Field(
19
32
  default=2000,
@@ -1,21 +1,29 @@
1
1
  from unittest.mock import AsyncMock, MagicMock
2
2
 
3
3
  import pytest
4
+ from unique_toolkit.language_model.default_language_model import (
5
+ DEFAULT_LANGUAGE_MODEL,
6
+ )
7
+ from unique_toolkit.language_model.infos import LanguageModelInfo
4
8
 
5
9
  from unique_user_memory.config import UserMemoryConfig
6
10
  from unique_user_memory.user_memory import (
7
11
  UserMemoryState,
8
12
  _sanitize_for_xml_context,
13
+ condense_user_memory,
9
14
  consolidate_user_memory,
10
15
  count_tokens,
11
16
  download_user_memory,
12
17
  enforce_token_cap,
13
18
  ensure_user_memory_folder,
19
+ fit_user_memory,
14
20
  upload_user_memory,
15
21
  )
16
22
  from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
17
23
  from unique_user_memory.user_memory_prompts import empty_profile
18
24
 
25
+ _TEST_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL)
26
+
19
27
 
20
28
  def test_enforce_token_cap_truncates_long_content() -> None:
21
29
  content = "\n\n".join(f"paragraph {index} " + "word " * 40 for index in range(50))
@@ -26,6 +34,113 @@ def test_enforce_token_cap_truncates_long_content() -> None:
26
34
  assert len(capped) < len(content)
27
35
 
28
36
 
37
+ def test_enforce_token_cap_keeps_body_when_section_exceeds_budget() -> None:
38
+ # A single section whose bullets are joined by single newlines used to be
39
+ # treated as one indivisible paragraph, dropping the whole body.
40
+ bullets = "\n".join(f"- fact number {index} about the user" for index in range(200))
41
+ content = f"# User Memory\n\n## Identity\n{bullets}"
42
+
43
+ capped = enforce_token_cap(content=content, max_tokens=120)
44
+
45
+ assert "<!-- truncated to fit memory budget -->" in capped
46
+ assert "# User Memory" in capped
47
+ assert "## Identity" in capped
48
+ # Some individual bullets survive rather than only the heading.
49
+ assert "- fact number 0 about the user" in capped
50
+ assert count_tokens(content=capped) <= 120
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_fit_user_memory_returns_unchanged_when_within_budget() -> None:
55
+ content = "# User Memory\n\n## Identity\n- short"
56
+
57
+ result = await fit_user_memory(
58
+ content=content,
59
+ max_tokens=2000,
60
+ language_model=_TEST_LANGUAGE_MODEL,
61
+ event=MagicMock(),
62
+ logger=MagicMock(),
63
+ )
64
+
65
+ assert result == content
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_fit_user_memory_condenses_before_hard_cut(
70
+ monkeypatch: pytest.MonkeyPatch,
71
+ ) -> None:
72
+ oversized = "# User Memory\n\n## Identity\n" + "\n".join(
73
+ f"- fact number {index} that is fairly wordy about the user"
74
+ for index in range(400)
75
+ )
76
+ condensed = "# User Memory\n\n## Identity\n- concise summary of the user"
77
+ condense = AsyncMock(return_value=condensed)
78
+ monkeypatch.setattr(
79
+ "unique_user_memory.user_memory.condense_user_memory",
80
+ condense,
81
+ )
82
+
83
+ result = await fit_user_memory(
84
+ content=oversized,
85
+ max_tokens=120,
86
+ language_model=_TEST_LANGUAGE_MODEL,
87
+ event=MagicMock(),
88
+ logger=MagicMock(),
89
+ )
90
+
91
+ assert result == condensed
92
+ condense.assert_awaited_once()
93
+ assert "<!-- truncated to fit memory budget -->" not in result
94
+
95
+
96
+ @pytest.mark.asyncio
97
+ async def test_fit_user_memory_hard_cuts_when_condense_fails(
98
+ monkeypatch: pytest.MonkeyPatch,
99
+ ) -> None:
100
+ oversized = "# User Memory\n\n## Identity\n" + "\n".join(
101
+ f"- fact number {index} about the user" for index in range(400)
102
+ )
103
+ monkeypatch.setattr(
104
+ "unique_user_memory.user_memory.condense_user_memory",
105
+ AsyncMock(return_value=None),
106
+ )
107
+
108
+ result = await fit_user_memory(
109
+ content=oversized,
110
+ max_tokens=120,
111
+ language_model=_TEST_LANGUAGE_MODEL,
112
+ event=MagicMock(),
113
+ logger=MagicMock(),
114
+ )
115
+
116
+ assert "<!-- truncated to fit memory budget -->" in result
117
+ assert count_tokens(content=result) <= 120
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_condense_user_memory_rejects_non_profile_output(
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ ) -> None:
124
+ response = MagicMock()
125
+ response.choices[0].message.content = "sorry, I cannot help"
126
+ llm_service = MagicMock()
127
+ llm_service.complete_async = AsyncMock(return_value=response)
128
+ monkeypatch.setattr(
129
+ "unique_user_memory.user_memory.LanguageModelService",
130
+ MagicMock(return_value=llm_service),
131
+ )
132
+
133
+ result = await condense_user_memory(
134
+ content="# User Memory\n\n## Identity\n- lots of stuff",
135
+ max_tokens=2000,
136
+ language_model=_TEST_LANGUAGE_MODEL,
137
+ event=MagicMock(),
138
+ logger=MagicMock(),
139
+ )
140
+
141
+ assert result is None
142
+
143
+
29
144
  def test_count_tokens_uses_language_model_encoder() -> None:
30
145
  language_model = MagicMock()
31
146
  language_model.get_encoder.return_value = lambda content: content.split()
@@ -58,6 +173,7 @@ async def test_consolidate_user_memory_keeps_existing_on_noop(
58
173
  user_message="hello",
59
174
  assistant_message="hi",
60
175
  config=UserMemoryConfig(),
176
+ language_model=_TEST_LANGUAGE_MODEL,
61
177
  event=MagicMock(),
62
178
  logger=MagicMock(),
63
179
  )
@@ -85,6 +201,7 @@ async def test_consolidate_user_memory_keeps_existing_on_malformed_output(
85
201
  user_message="remember I like concise answers",
86
202
  assistant_message="noted",
87
203
  config=UserMemoryConfig(),
204
+ language_model=_TEST_LANGUAGE_MODEL,
88
205
  event=MagicMock(),
89
206
  logger=MagicMock(),
90
207
  )
@@ -381,6 +498,7 @@ async def test_user_memory_postprocessor_logs_success_when_upload_succeeds(
381
498
  logger = MagicMock()
382
499
  postprocessor = UserMemoryPostprocessor(
383
500
  config=UserMemoryConfig(),
501
+ language_model=_TEST_LANGUAGE_MODEL,
384
502
  event=event,
385
503
  state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
386
504
  logger=logger,
@@ -422,6 +540,7 @@ async def test_user_memory_postprocessor_does_not_log_success_when_upload_fails(
422
540
  logger = MagicMock()
423
541
  postprocessor = UserMemoryPostprocessor(
424
542
  config=UserMemoryConfig(),
543
+ language_model=_TEST_LANGUAGE_MODEL,
425
544
  event=event,
426
545
  state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
427
546
  logger=logger,
@@ -23,6 +23,8 @@ from unique_toolkit.language_model.infos import LanguageModelInfo
23
23
  from unique_user_memory.config import UserMemoryConfig
24
24
  from unique_user_memory.user_memory_prompts import (
25
25
  SECTION_HEADINGS,
26
+ condensation_system_prompt,
27
+ condensation_user_prompt,
26
28
  consolidation_system_prompt,
27
29
  consolidation_user_prompt,
28
30
  empty_profile,
@@ -31,6 +33,9 @@ from unique_user_memory.user_memory_prompts import (
31
33
  MEMORY_FILENAME = "memory.md"
32
34
  MIME_TYPE = "text/markdown"
33
35
  _LLM_OUTPUT_HEADROOM_TOKENS = 200
36
+ # When condensing an oversized profile, aim below the hard cap so the LLM
37
+ # output leaves headroom and the hard-cut safety net rarely has to fire.
38
+ _CONDENSE_TARGET_RATIO = 0.9
34
39
  _TRUNCATION_MARKER = "\n\n<!-- truncated to fit memory budget -->"
35
40
  _DEFAULT_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_GPT_4o)
36
41
  _FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
@@ -85,18 +90,22 @@ def enforce_token_cap(
85
90
 
86
91
  marker_tokens = _count_tokens(content=_TRUNCATION_MARKER, encode=encode)
87
92
  target = max(0, max_tokens - marker_tokens)
88
- paragraphs = content.split("\n\n")
93
+ # Split on lines rather than blank-line paragraphs: memory profiles keep
94
+ # each bullet on its own line inside a section, so paragraph-level cuts
95
+ # would treat a whole (multi-thousand-token) section as one indivisible
96
+ # unit and drop the entire body once it exceeds the budget.
97
+ lines = content.split("\n")
89
98
  accepted: list[str] = []
90
99
  running = 0
91
- for paragraph in paragraphs:
92
- paragraph_tokens = _count_tokens(content=paragraph + "\n\n", encode=encode)
93
- if running + paragraph_tokens > target:
100
+ for line in lines:
101
+ line_tokens = _count_tokens(content=line + "\n", encode=encode)
102
+ if running + line_tokens > target:
94
103
  break
95
- accepted.append(paragraph)
96
- running += paragraph_tokens
104
+ accepted.append(line)
105
+ running += line_tokens
97
106
 
98
107
  if accepted:
99
- truncated = "\n\n".join(accepted)
108
+ truncated = "\n".join(accepted)
100
109
  else:
101
110
  truncated = decode(encode(content)[:target])
102
111
 
@@ -113,10 +122,164 @@ def enforce_token_cap(
113
122
  return result
114
123
 
115
124
 
125
+ async def condense_user_memory(
126
+ *,
127
+ content: str,
128
+ max_tokens: int,
129
+ language_model: LanguageModelInfo,
130
+ event: ChatEvent,
131
+ logger: Logger,
132
+ ) -> str | None:
133
+ """Ask the LLM to rewrite an oversized profile into a shorter one.
134
+
135
+ Removes duplicate and outdated bullets and tightens prose, targeting a
136
+ fraction of the hard cap so the result leaves headroom. Returns the
137
+ condensed profile, or ``None`` when the call fails or the output does
138
+ not look like a profile (the caller then falls back to a hard cut).
139
+ """
140
+ current_tokens = count_tokens(content=content, language_model=language_model)
141
+ target_tokens = max(1, int(max_tokens * _CONDENSE_TARGET_RATIO))
142
+
143
+ try:
144
+ llm_service = LanguageModelService(event)
145
+ except Exception as exc:
146
+ logger.warning(
147
+ "[user-memory] cannot construct LanguageModelService for condense: [%s] %s",
148
+ type(exc).__name__,
149
+ exc,
150
+ )
151
+ return None
152
+
153
+ messages = LanguageModelMessages(
154
+ [
155
+ LanguageModelSystemMessage(
156
+ content=condensation_system_prompt(
157
+ max_tokens=max_tokens,
158
+ current_tokens=current_tokens,
159
+ target_tokens=target_tokens,
160
+ )
161
+ ),
162
+ LanguageModelUserMessage(
163
+ content=condensation_user_prompt(_sanitize_for_xml_context(content))
164
+ ),
165
+ ]
166
+ )
167
+
168
+ try:
169
+ response = await llm_service.complete_async(
170
+ messages=messages,
171
+ model_name=language_model.name,
172
+ other_options={"max_tokens": max_tokens + _LLM_OUTPUT_HEADROOM_TOKENS},
173
+ )
174
+ except Exception as exc:
175
+ logger.warning(
176
+ "[user-memory] condense LLM call failed (model=%s): [%s] %s",
177
+ language_model.name,
178
+ type(exc).__name__,
179
+ exc,
180
+ )
181
+ return None
182
+
183
+ try:
184
+ raw = response.choices[0].message.content or ""
185
+ except Exception as exc:
186
+ logger.warning(
187
+ "[user-memory] could not extract content from condense response: [%s] %s",
188
+ type(exc).__name__,
189
+ exc,
190
+ )
191
+ return None
192
+
193
+ if not isinstance(raw, str):
194
+ logger.warning(
195
+ "[user-memory] condense returned non-string content (%s)",
196
+ type(raw).__name__,
197
+ )
198
+ return None
199
+
200
+ candidate = _strip_code_fences(raw).strip()
201
+ if not _is_well_formed_profile(candidate):
202
+ logger.warning(
203
+ "[user-memory] condense output did not look like a profile (%d chars)",
204
+ len(candidate),
205
+ )
206
+ return None
207
+
208
+ return candidate
209
+
210
+
211
+ async def fit_user_memory(
212
+ *,
213
+ content: str,
214
+ max_tokens: int,
215
+ language_model: LanguageModelInfo,
216
+ event: ChatEvent,
217
+ logger: Logger,
218
+ ) -> str:
219
+ """Ensure ``content`` fits ``max_tokens``, condensing before cutting.
220
+
221
+ Fast path: content already within budget is returned untouched (no LLM
222
+ call). Otherwise the profile is first condensed by the LLM, and only a
223
+ still-oversized result is hard-cut by :func:`enforce_token_cap`.
224
+ """
225
+ if not content:
226
+ return content
227
+
228
+ if count_tokens(content=content, language_model=language_model) <= max_tokens:
229
+ return content
230
+
231
+ current_tokens = count_tokens(content=content, language_model=language_model)
232
+ logger.info(
233
+ "[user-memory] memory over budget (%d > %d tokens) - condensing via LLM",
234
+ current_tokens,
235
+ max_tokens,
236
+ )
237
+ condensed = await condense_user_memory(
238
+ content=content,
239
+ max_tokens=max_tokens,
240
+ language_model=language_model,
241
+ event=event,
242
+ logger=logger,
243
+ )
244
+ if condensed is not None:
245
+ condensed_tokens = count_tokens(
246
+ content=condensed, language_model=language_model
247
+ )
248
+ if condensed_tokens <= max_tokens:
249
+ logger.info(
250
+ "[user-memory] memory condensed from %d to %d tokens (cap=%d)",
251
+ current_tokens,
252
+ condensed_tokens,
253
+ max_tokens,
254
+ )
255
+ return condensed
256
+ logger.info(
257
+ "[user-memory] still over budget after condense (%d > %d) - "
258
+ "applying hard cut",
259
+ condensed_tokens,
260
+ max_tokens,
261
+ )
262
+ content = condensed
263
+
264
+ result = enforce_token_cap(
265
+ content=content,
266
+ max_tokens=max_tokens,
267
+ language_model=language_model,
268
+ )
269
+ logger.info(
270
+ "[user-memory] memory condensed from %d to %d tokens (cap=%d)",
271
+ current_tokens,
272
+ count_tokens(content=result, language_model=language_model),
273
+ max_tokens,
274
+ )
275
+ return result
276
+
277
+
116
278
  async def load_user_memory(
117
279
  *,
118
280
  event: ChatEvent,
119
281
  config: UserMemoryConfig,
282
+ language_model: LanguageModelInfo,
120
283
  logger: Logger,
121
284
  ) -> UserMemoryState | None:
122
285
  user_id = event.user_id
@@ -143,10 +306,12 @@ async def load_user_memory(
143
306
  )
144
307
  return UserMemoryState(
145
308
  scope_id=scope_id,
146
- text=enforce_token_cap(
309
+ text=await fit_user_memory(
147
310
  content=text,
148
311
  max_tokens=config.max_tokens,
149
- language_model=config.language_model,
312
+ language_model=language_model,
313
+ event=event,
314
+ logger=logger,
150
315
  ),
151
316
  )
152
317
 
@@ -393,18 +558,19 @@ async def consolidate_user_memory(
393
558
  user_message: str,
394
559
  assistant_message: str,
395
560
  config: UserMemoryConfig,
561
+ language_model: LanguageModelInfo,
396
562
  event: ChatEvent,
397
563
  logger: Logger,
398
564
  ) -> str:
399
565
  safe_current = enforce_token_cap(
400
566
  content=current_memory,
401
567
  max_tokens=config.max_tokens,
402
- language_model=config.language_model,
568
+ language_model=language_model,
403
569
  )
404
570
  logger.info(
405
571
  "[user-memory] consolidating turn - existing_memory_tokens=%d | "
406
572
  "user_msg_chars=%d | assistant_msg_chars=%d",
407
- count_tokens(content=safe_current, language_model=config.language_model),
573
+ count_tokens(content=safe_current, language_model=language_model),
408
574
  len(user_message or ""),
409
575
  len(assistant_message or ""),
410
576
  )
@@ -413,7 +579,7 @@ async def consolidate_user_memory(
413
579
  return safe_current or enforce_token_cap(
414
580
  content=empty_profile(user_id),
415
581
  max_tokens=config.max_tokens,
416
- language_model=config.language_model,
582
+ language_model=language_model,
417
583
  )
418
584
 
419
585
  if not safe_current.strip():
@@ -450,7 +616,7 @@ async def consolidate_user_memory(
450
616
  try:
451
617
  response = await llm_service.complete_async(
452
618
  messages=messages,
453
- model_name=config.language_model.name,
619
+ model_name=language_model.name,
454
620
  other_options={
455
621
  "max_tokens": config.max_tokens + _LLM_OUTPUT_HEADROOM_TOKENS,
456
622
  },
@@ -458,7 +624,7 @@ async def consolidate_user_memory(
458
624
  except Exception as exc:
459
625
  logger.warning(
460
626
  "[user-memory] consolidation LLM call failed (model=%s): [%s] %s",
461
- config.language_model.name,
627
+ language_model.name,
462
628
  type(exc).__name__,
463
629
  exc,
464
630
  )
@@ -493,10 +659,12 @@ async def consolidate_user_memory(
493
659
  )
494
660
  return safe_current
495
661
 
496
- capped = enforce_token_cap(
662
+ capped = await fit_user_memory(
497
663
  content=candidate,
498
664
  max_tokens=config.max_tokens,
499
- language_model=config.language_model,
665
+ language_model=language_model,
666
+ event=event,
667
+ logger=logger,
500
668
  )
501
669
  if (
502
670
  safe_current
@@ -508,7 +676,7 @@ async def consolidate_user_memory(
508
676
 
509
677
  logger.info(
510
678
  "[user-memory] consolidation produced %d tokens (cap=%d)",
511
- count_tokens(content=capped, language_model=config.language_model),
679
+ count_tokens(content=capped, language_model=language_model),
512
680
  config.max_tokens,
513
681
  )
514
682
  return capped
@@ -2,6 +2,10 @@ from logging import Logger
2
2
 
3
3
  from unique_toolkit.agentic.postprocessor.postprocessor_manager import Postprocessor
4
4
  from unique_toolkit.app.schemas import ChatEvent
5
+ from unique_toolkit.language_model.default_language_model import (
6
+ DEFAULT_LANGUAGE_MODEL,
7
+ )
8
+ from unique_toolkit.language_model.infos import LanguageModelInfo
5
9
  from unique_toolkit.language_model.schemas import LanguageModelStreamResponse
6
10
 
7
11
  from unique_user_memory.config import UserMemoryConfig
@@ -17,12 +21,20 @@ class UserMemoryPostprocessor(Postprocessor):
17
21
  self,
18
22
  *,
19
23
  config: UserMemoryConfig,
24
+ language_model: LanguageModelInfo = LanguageModelInfo.from_name(
25
+ DEFAULT_LANGUAGE_MODEL
26
+ ),
20
27
  event: ChatEvent,
21
28
  state: UserMemoryState,
22
29
  logger: Logger,
23
30
  ) -> None:
24
31
  super().__init__(name="UserMemoryPostprocessor")
25
32
  self._config = config
33
+ self._language_model = (
34
+ language_model
35
+ if config.use_orchestrator_language_model
36
+ else config.language_model
37
+ )
26
38
  self._event = event
27
39
  self._state = state
28
40
  self._logger = logger
@@ -41,6 +53,7 @@ class UserMemoryPostprocessor(Postprocessor):
41
53
  user_message=self._event.payload.user_message.text or "",
42
54
  assistant_message=loop_response.message.text or "",
43
55
  config=self._config,
56
+ language_model=self._language_model,
44
57
  event=self._event,
45
58
  logger=self._logger,
46
59
  )
@@ -75,8 +75,10 @@ For each candidate fact in `<new_turn>`, decide one of:
75
75
  - ADD - the fact is new and stable enough to remember (preferences,
76
76
  identity attributes, ongoing projects, skills, dated topics). Add it as
77
77
  a bullet in the most appropriate section.
78
- - UPDATE - the fact refines or supersedes an existing bullet. Edit
79
- the existing bullet in place; do not add a duplicate.
78
+ - UPDATE - the fact refines, supersedes, or contradicts an existing
79
+ bullet. Overwrite the existing bullet in place with the new
80
+ information; do not add a duplicate and do not keep the old version
81
+ alongside the new one.
80
82
  - DELETE - the new turn explicitly contradicts or invalidates an
81
83
  existing bullet that is not worth keeping as history. Remove it.
82
84
  - NOOP - the new turn contains no facts about the user (small talk,
@@ -87,6 +89,26 @@ For each candidate fact in `<new_turn>`, decide one of:
87
89
  Prefer UPDATE over ADD when in doubt - duplication is the most common
88
90
  failure mode of memory systems.
89
91
 
92
+ # Resolving contradictions - ALWAYS
93
+
94
+ When a new statement contradicts an existing bullet (a changed
95
+ preference, a corrected fact, an updated status), the new statement
96
+ always wins. Overwrite the old bullet with the new information and
97
+ remove the outdated version. Two contradictory bullets must never
98
+ coexist in the profile - for example, do not keep both "Prefers all
99
+ responses in German" and "Prefers responses in English". Resolve the
100
+ conflict decisively in favour of the most recent statement, even when
101
+ the older bullet is in a different position or worded differently.
102
+
103
+ # Consolidating within sections - ALWAYS
104
+
105
+ Sections tend to grow with bullets that state the same or overlapping
106
+ information in different words. Before returning the profile, review
107
+ each section and merge bullets that are duplicates or semantically
108
+ similar (same meaning, different wording) into a single clear bullet.
109
+ A section must never accumulate redundant or near-duplicate statements.
110
+ Consolidate on every turn, not only when approaching the word budget.
111
+
90
112
  # What to extract
91
113
 
92
114
  ADD/UPDATE for facts that are:
@@ -155,6 +177,91 @@ def consolidation_system_prompt(max_tokens: int) -> str:
155
177
  )
156
178
 
157
179
 
180
+ _CONDENSATION_SYSTEM_PROMPT_TEMPLATE = """\
181
+ You are a memory-compaction engine for the Unique AI platform.
182
+
183
+ You are given an existing user-memory profile (Markdown with YAML
184
+ frontmatter) that is OVER its size budget. Your job is to rewrite it so
185
+ it becomes materially SHORTER while preserving every durable, high-signal
186
+ fact about the user. This is lossy compression, not deletion of meaning.
187
+
188
+ # Size target - STRICT
189
+
190
+ - The current profile is about {{ current_tokens }} tokens.
191
+ - You MUST bring it down to at most {{ target_tokens }} tokens
192
+ (roughly {{ target_words }} words) - about a {{ reduction_pct }}%
193
+ reduction. Aim comfortably under the target; do not stop early.
194
+
195
+ # How to shrink (in priority order)
196
+
197
+ 1. Merge duplicate and near-duplicate bullets that state the same or
198
+ overlapping information into a single clear bullet. Redundancy is the
199
+ main reason this profile is oversized - collapse it aggressively.
200
+ 2. Delete outdated, stale, resolved, or superseded entries: old
201
+ "Recent Topics", answered "Open Questions / Follow-ups", and facts a
202
+ later bullet already contradicts or refines.
203
+ 3. Tighten verbose, flowery, or repetitive prose into short factual
204
+ bullets. Remove hedging and filler.
205
+ 4. Fold low-signal "Work Context" and "Skills & Expertise" bullets into
206
+ broader summary bullets.
207
+ 5. "Identity" and "Communication Preferences" carry the most durable
208
+ signal - tighten and de-duplicate them, but never drop a genuinely
209
+ distinct fact or preference.
210
+
211
+ # Hard rules
212
+
213
+ - NEVER invent, embellish, or add facts that are not already present.
214
+ - Preserve the YAML frontmatter. Keep `user_id` and `schema_version`
215
+ exactly; keep `last_updated` and `turn_count` as they are.
216
+ - Keep exactly these section headings, in this order, even if a section
217
+ becomes empty (use the literal string `_(empty)_`):
218
+
219
+ {{ section_list }}
220
+
221
+ - Resolve contradictions in favour of the most recent statement; never
222
+ keep two conflicting bullets.
223
+ - Use `-` markdown bullets, no nesting beyond two levels, no emojis.
224
+
225
+ # Output
226
+
227
+ Return ONLY the complete rewritten profile file - frontmatter followed by
228
+ the body. Do NOT emit a diff, do NOT wrap the output in ``` fences, and
229
+ do NOT add any commentary before or after the file.
230
+ """
231
+
232
+
233
+ def condensation_system_prompt(
234
+ *,
235
+ max_tokens: int,
236
+ current_tokens: int,
237
+ target_tokens: int,
238
+ ) -> str:
239
+ section_list = "\n".join(f"- ## {heading}" for heading in SECTION_HEADINGS)
240
+ safe_current = max(current_tokens, target_tokens + 1)
241
+ reduction_pct = int(round((1 - target_tokens / safe_current) * 100))
242
+ return Template(_CONDENSATION_SYSTEM_PROMPT_TEMPLATE).render(
243
+ section_list=section_list,
244
+ current_tokens=current_tokens,
245
+ target_tokens=target_tokens,
246
+ target_words=int(target_tokens * 0.75),
247
+ reduction_pct=max(reduction_pct, 1),
248
+ max_tokens=max_tokens,
249
+ )
250
+
251
+
252
+ _CONDENSATION_USER_PROMPT_TEMPLATE = """\
253
+ <profile_to_condense>
254
+ {{ profile }}
255
+ </profile_to_condense>
256
+
257
+ Return the complete, condensed profile file now.
258
+ """
259
+
260
+
261
+ def condensation_user_prompt(profile: str) -> str:
262
+ return Template(_CONDENSATION_USER_PROMPT_TEMPLATE).render(profile=profile)
263
+
264
+
158
265
  _CONSOLIDATION_USER_PROMPT_TEMPLATE = """\
159
266
  User ID: {{ user_id }}
160
267