unique-user-memory 2026.32.0.dev1__tar.gz → 2026.32.0.dev2__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,14 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-user-memory
3
- Version: 2026.32.0.dev1
3
+ Version: 2026.32.0.dev2
4
4
  Summary:
5
5
  Author: Fabian Schläpfer
6
6
  Author-email: Fabian Schläpfer <fabian@unique.ch>
7
7
  License: Proprietary
8
8
  Requires-Dist: jinja2>=3.1.6
9
9
  Requires-Dist: pydantic>=2.8.2
10
- Requires-Dist: unique-sdk>=2026.32.0.dev0,<2026.32.0rc0
11
- Requires-Dist: unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0
10
+ Requires-Dist: unique-sdk>=2026.32.0.dev5,<2026.32.0rc0
11
+ Requires-Dist: unique-toolkit>=2026.32.0.dev4,<2026.32.0rc0
12
12
  Requires-Python: >=3.12, <4
13
13
  Description-Content-Type: text/markdown
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_user_memory"
3
- version = "2026.32.0.dev1"
3
+ version = "2026.32.0.dev2"
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.32.0.dev0,<2026.32.0rc0",
15
- "unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0",
14
+ "unique-sdk>=2026.32.0.dev5,<2026.32.0rc0",
15
+ "unique-toolkit>=2026.32.0.dev4,<2026.32.0rc0",
16
16
  ]
17
17
 
18
18
  [dependency-groups]
@@ -6,6 +6,8 @@ from unique_toolkit.language_model.default_language_model import (
6
6
  DEFAULT_LANGUAGE_MODEL,
7
7
  )
8
8
  from unique_toolkit.language_model.infos import LanguageModelInfo
9
+ from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
10
+ from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
9
11
 
10
12
  from unique_user_memory.config import UserMemoryConfig
11
13
  from unique_user_memory.user_memory import (
@@ -1088,6 +1090,106 @@ async def test_user_memory_postprocessor_logs_success_when_upload_succeeds(
1088
1090
  )
1089
1091
 
1090
1092
 
1093
+ @pytest.mark.ai
1094
+ @pytest.mark.asyncio
1095
+ async def test_user_memory_postprocessor_run_resets_invocation_stats(
1096
+ monkeypatch: pytest.MonkeyPatch,
1097
+ ) -> None:
1098
+ """Purpose: Verify each run reports only usage attributable to that run.
1099
+ Why this matters: Reused postprocessors must not inflate token analytics.
1100
+ Setup summary: Run twice with distinct usage and assert the second excludes the first.
1101
+ """
1102
+ load_stats = LanguageModelInvocationStats.from_usage(
1103
+ _TEST_LANGUAGE_MODEL.name,
1104
+ LanguageModelTokenUsage(total_tokens=2),
1105
+ source="user_memory_load_condense",
1106
+ )
1107
+ first_run_stats = LanguageModelInvocationStats.from_usage(
1108
+ _TEST_LANGUAGE_MODEL.name,
1109
+ LanguageModelTokenUsage(total_tokens=3),
1110
+ source="user_memory_consolidate_first",
1111
+ )
1112
+ second_run_stats = LanguageModelInvocationStats.from_usage(
1113
+ _TEST_LANGUAGE_MODEL.name,
1114
+ LanguageModelTokenUsage(total_tokens=5),
1115
+ source="user_memory_consolidate_second",
1116
+ )
1117
+ run_stats = iter((first_run_stats, second_run_stats))
1118
+
1119
+ async def consolidate(*, invocation_stats, **kwargs) -> str: # type: ignore[no-untyped-def]
1120
+ invocation_stats.append(next(run_stats))
1121
+ return "# User Memory\n\n## Identity\n- unchanged"
1122
+
1123
+ monkeypatch.setattr(
1124
+ "unique_user_memory.user_memory_postprocessor.consolidate_user_memory",
1125
+ consolidate,
1126
+ )
1127
+ event = MagicMock()
1128
+ event.user_id = "user_1"
1129
+ event.company_id = "company_1"
1130
+ event.payload.user_message.text = "remember this"
1131
+ loop_response = MagicMock()
1132
+ loop_response.message.text = "noted"
1133
+ state = UserMemoryState(
1134
+ scope_id="scope_1",
1135
+ text="# User Memory\n\n## Identity\n- unchanged",
1136
+ load_invocation_stats=(load_stats,),
1137
+ )
1138
+ postprocessor = UserMemoryPostprocessor(
1139
+ config=UserMemoryConfig(),
1140
+ language_model=_TEST_LANGUAGE_MODEL,
1141
+ event=event,
1142
+ state=state,
1143
+ logger=MagicMock(),
1144
+ chat_service=MagicMock(),
1145
+ )
1146
+
1147
+ await postprocessor.run(loop_response)
1148
+ first_reported_stats = postprocessor.invocation_stats
1149
+ await postprocessor.run(loop_response)
1150
+
1151
+ assert first_reported_stats == [load_stats, first_run_stats]
1152
+ assert postprocessor.invocation_stats == [second_run_stats]
1153
+
1154
+
1155
+ @pytest.mark.ai
1156
+ def test_user_memory_postprocessor_take_pending_invocation_stats_drains_once() -> None:
1157
+ """Purpose: Verify load-time usage is reported exactly once, however it's read.
1158
+ Why this matters: A turn that exits before `run()` (cancellation, empty
1159
+ response, a control-taking tool) must still report the load-time condense
1160
+ tokens, and a turn that does reach `run()` must not double-count them.
1161
+ Setup summary: Take the pending stats directly, then run(), and assert
1162
+ run() no longer reports the already-taken load stats.
1163
+ """
1164
+ load_stats = LanguageModelInvocationStats.from_usage(
1165
+ _TEST_LANGUAGE_MODEL.name,
1166
+ LanguageModelTokenUsage(total_tokens=2),
1167
+ source="user_memory_load_condense",
1168
+ )
1169
+ event = MagicMock()
1170
+ event.user_id = "user_1"
1171
+ event.company_id = "company_1"
1172
+ event.payload.user_message.text = "remember this"
1173
+ state = UserMemoryState(
1174
+ scope_id="scope_1",
1175
+ text="# User Memory\n\n## Identity\n- unchanged",
1176
+ load_invocation_stats=(load_stats,),
1177
+ )
1178
+ postprocessor = UserMemoryPostprocessor(
1179
+ config=UserMemoryConfig(),
1180
+ language_model=_TEST_LANGUAGE_MODEL,
1181
+ event=event,
1182
+ state=state,
1183
+ logger=MagicMock(),
1184
+ chat_service=MagicMock(),
1185
+ )
1186
+
1187
+ taken = postprocessor.take_pending_invocation_stats()
1188
+
1189
+ assert taken == [load_stats]
1190
+ assert postprocessor.take_pending_invocation_stats() == []
1191
+
1192
+
1091
1193
  @pytest.mark.asyncio
1092
1194
  async def test_user_memory_postprocessor_does_not_log_success_when_upload_fails(
1093
1195
  monkeypatch: pytest.MonkeyPatch,
@@ -21,6 +21,9 @@ from unique_toolkit.language_model import (
21
21
  TypeEncoder,
22
22
  )
23
23
  from unique_toolkit.language_model.infos import LanguageModelInfo
24
+ from unique_toolkit.language_model.invocation_stats import (
25
+ LanguageModelInvocationStats,
26
+ )
24
27
 
25
28
  from unique_user_memory.config import UserMemoryConfig
26
29
  from unique_user_memory.user_memory_prompts import (
@@ -97,6 +100,7 @@ def _restore_frontmatter(original: str, body: str) -> str:
97
100
  class UserMemoryState:
98
101
  scope_id: str
99
102
  text: str
103
+ load_invocation_stats: tuple[LanguageModelInvocationStats, ...] = ()
100
104
 
101
105
 
102
106
  def _get_model_tokenizer(
@@ -181,6 +185,8 @@ async def condense_user_memory(
181
185
  language_model: LanguageModelInfo,
182
186
  event: ChatEvent,
183
187
  logger: Logger,
188
+ invocation_stats: list[LanguageModelInvocationStats] | None = None,
189
+ invocation_source: str = "user_memory_condense",
184
190
  ) -> str | None:
185
191
  """Ask the LLM to rewrite an oversized profile into a shorter one.
186
192
 
@@ -233,6 +239,15 @@ async def condense_user_memory(
233
239
  )
234
240
  return None
235
241
 
242
+ if invocation_stats is not None and response.usage is not None:
243
+ invocation_stats.append(
244
+ LanguageModelInvocationStats.from_usage(
245
+ language_model.name,
246
+ response.usage,
247
+ source=invocation_source,
248
+ )
249
+ )
250
+
236
251
  try:
237
252
  raw = response.choices[0].message.content or ""
238
253
  except Exception as exc:
@@ -268,6 +283,8 @@ async def fit_user_memory(
268
283
  language_model: LanguageModelInfo,
269
284
  event: ChatEvent,
270
285
  logger: Logger,
286
+ invocation_stats: list[LanguageModelInvocationStats] | None = None,
287
+ invocation_source: str = "user_memory_condense",
271
288
  ) -> str:
272
289
  """Ensure ``content`` fits ``max_tokens``, condensing before cutting.
273
290
 
@@ -293,6 +310,8 @@ async def fit_user_memory(
293
310
  language_model=language_model,
294
311
  event=event,
295
312
  logger=logger,
313
+ invocation_stats=invocation_stats,
314
+ invocation_source=invocation_source,
296
315
  )
297
316
  if condensed is not None:
298
317
  condensed = _restore_frontmatter(content, condensed)
@@ -358,6 +377,7 @@ async def load_user_memory(
358
377
  company_id=company_id,
359
378
  logger=logger,
360
379
  )
380
+ invocation_stats: list[LanguageModelInvocationStats] = []
361
381
  return UserMemoryState(
362
382
  scope_id=scope_id,
363
383
  text=await fit_user_memory(
@@ -366,7 +386,10 @@ async def load_user_memory(
366
386
  language_model=language_model,
367
387
  event=event,
368
388
  logger=logger,
389
+ invocation_stats=invocation_stats,
390
+ invocation_source="user_memory_load_condense",
369
391
  ),
392
+ load_invocation_stats=tuple(invocation_stats),
370
393
  )
371
394
 
372
395
 
@@ -614,6 +637,7 @@ async def should_consolidate_memory(
614
637
  language_model: LanguageModelInfo,
615
638
  event: ChatEvent,
616
639
  logger: Logger,
640
+ invocation_stats: list[LanguageModelInvocationStats] | None = None,
617
641
  ) -> bool:
618
642
  """Cheaply decide whether the turn warrants a full memory rewrite.
619
643
 
@@ -664,6 +688,15 @@ async def should_consolidate_memory(
664
688
  )
665
689
  return True
666
690
 
691
+ if invocation_stats is not None and response.usage is not None:
692
+ invocation_stats.append(
693
+ LanguageModelInvocationStats.from_usage(
694
+ language_model.name,
695
+ response.usage,
696
+ source="user_memory_gate",
697
+ )
698
+ )
699
+
667
700
  try:
668
701
  raw = response.choices[0].message.content or ""
669
702
  except Exception as exc:
@@ -702,6 +735,7 @@ async def consolidate_user_memory(
702
735
  logger: Logger,
703
736
  on_update_start: Callable[[], Awaitable[None]] = noop_update_callback,
704
737
  on_update_end: Callable[[], Awaitable[None]] = noop_update_callback,
738
+ invocation_stats: list[LanguageModelInvocationStats] | None = None,
705
739
  ) -> str:
706
740
  """Consolidate the latest turn into the user's memory profile.
707
741
 
@@ -739,6 +773,7 @@ async def consolidate_user_memory(
739
773
  language_model=language_model,
740
774
  event=event,
741
775
  logger=logger,
776
+ invocation_stats=invocation_stats,
742
777
  ):
743
778
  return safe_current
744
779
 
@@ -753,6 +788,7 @@ async def consolidate_user_memory(
753
788
  language_model=language_model,
754
789
  event=event,
755
790
  logger=logger,
791
+ invocation_stats=invocation_stats,
756
792
  )
757
793
  finally:
758
794
  await on_update_end()
@@ -768,6 +804,7 @@ async def _rewrite_user_memory(
768
804
  language_model: LanguageModelInfo,
769
805
  event: ChatEvent,
770
806
  logger: Logger,
807
+ invocation_stats: list[LanguageModelInvocationStats] | None = None,
771
808
  ) -> str:
772
809
  if not safe_current.strip():
773
810
  safe_current = empty_profile(user_id)
@@ -816,6 +853,15 @@ async def _rewrite_user_memory(
816
853
  )
817
854
  return safe_current
818
855
 
856
+ if invocation_stats is not None and response.usage is not None:
857
+ invocation_stats.append(
858
+ LanguageModelInvocationStats.from_usage(
859
+ language_model.name,
860
+ response.usage,
861
+ source="user_memory_consolidation",
862
+ )
863
+ )
864
+
819
865
  try:
820
866
  raw = response.choices[0].message.content or ""
821
867
  except Exception as exc:
@@ -860,6 +906,8 @@ async def _rewrite_user_memory(
860
906
  language_model=language_model,
861
907
  event=event,
862
908
  logger=logger,
909
+ invocation_stats=invocation_stats,
910
+ invocation_source="user_memory_post_consolidation_condense",
863
911
  )
864
912
  logger.info(
865
913
  "[user-memory] consolidation produced %d tokens (cap=%d)",
@@ -9,6 +9,7 @@ from unique_toolkit.language_model.default_language_model import (
9
9
  DEFAULT_LANGUAGE_MODEL,
10
10
  )
11
11
  from unique_toolkit.language_model.infos import LanguageModelInfo
12
+ from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
12
13
  from unique_toolkit.language_model.schemas import LanguageModelStreamResponse
13
14
 
14
15
  from unique_user_memory.config import UserMemoryConfig
@@ -49,6 +50,28 @@ class UserMemoryPostprocessor(Postprocessor):
49
50
  self._logger = logger
50
51
  self._new_memory: str | None = None
51
52
  self._chat_service: ChatService = chat_service
53
+ self._pending_load_invocation_stats = list(state.load_invocation_stats)
54
+ self._invocation_stats: list[LanguageModelInvocationStats] = []
55
+
56
+ @property
57
+ def invocation_stats(self) -> list[LanguageModelInvocationStats]:
58
+ return list(self._invocation_stats)
59
+
60
+ def take_pending_invocation_stats(self) -> list[LanguageModelInvocationStats]:
61
+ """Pop load-time condense stats not yet reported.
62
+
63
+ `UniqueAI` calls this unconditionally at the start of every turn so a
64
+ turn that exits before `run()` (cancellation, empty response, a
65
+ control-taking tool) still reports the tokens spent condensing the
66
+ loaded profile. If `run()` does execute, it drains the same pending
67
+ list itself, so whichever of the two runs first "wins" and the other
68
+ sees an empty list -- the tokens are never double-counted or lost.
69
+ """
70
+ stats, self._pending_load_invocation_stats = (
71
+ self._pending_load_invocation_stats,
72
+ [],
73
+ )
74
+ return stats
52
75
 
53
76
  async def run(self, loop_response: LanguageModelStreamResponse) -> bool:
54
77
  """Consolidate and upload user memory for this turn.
@@ -56,6 +79,7 @@ class UserMemoryPostprocessor(Postprocessor):
56
79
  Returns True if the memory profile changed and was uploaded, False
57
80
  otherwise (no user/company, NOOP consolidation, or failed upload).
58
81
  """
82
+ self._invocation_stats = self.take_pending_invocation_stats()
59
83
  self._logger.info("[user-memory] running postprocessor")
60
84
  user_id = self._event.user_id
61
85
  company_id = self._event.company_id
@@ -99,6 +123,7 @@ class UserMemoryPostprocessor(Postprocessor):
99
123
  logger=self._logger,
100
124
  on_update_start=on_update_start,
101
125
  on_update_end=on_update_end,
126
+ invocation_stats=self._invocation_stats,
102
127
  )
103
128
 
104
129
  if self._new_memory == self._state.text: