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.
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/PKG-INFO +3 -3
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/pyproject.toml +3 -3
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/tests/test_user_memory.py +102 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/user_memory.py +48 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/user_memory_postprocessor.py +25 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/README.md +0 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/config.py +0 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/user_memory_prompts.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-user-memory
|
|
3
|
-
Version: 2026.32.0.
|
|
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.
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.32.0.
|
|
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.
|
|
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.
|
|
15
|
-
"unique-toolkit>=2026.32.0.
|
|
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:
|
|
File without changes
|
|
File without changes
|
{unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev2}/unique_user_memory/config.py
RENAMED
|
File without changes
|
|
File without changes
|