unique-user-memory 2026.38.0.dev2__tar.gz → 2026.40.0.dev0__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.40.0.dev0
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.40.0.dev0,<2026.40.0rc0
11
+ Requires-Dist: unique-toolkit[monitoring]>=2026.40.0.dev0,<2026.40.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.
@@ -130,6 +144,27 @@ config = UserMemoryConfig(
130
144
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
131
145
  | `root_folder` | `user-memory` | Subfolder name under each user's home folder (`/home-<user_id>/<root_folder>`) that holds the memory profile; also read as a legacy fallback at `/<root_folder>/<user_id>`. |
132
146
 
147
+ ## Observability
148
+
149
+ The package registers Prometheus metrics on the shared `unique_toolkit.monitoring` registry (namespace `unique_user_memory`). They appear on whatever host already scrapes `GET /metrics` (assistants-core). Labels are closed enums — no company or user ids. LLM series include the model name so latency can be split per deployed model.
150
+
151
+ QA dashboard: [User Memory](https://qa-grafana.alpine-bowfin.ts.net/d/unique-user-memory) (`unique-user-memory`). The dashboard is provisioned by the [assistants-core Helm chart](https://github.com/Unique-AG/monorepo/tree/master/python/assistants/bundles/core/deploy/helm-chart/files/grafana/dashboards).
152
+
153
+ | Metric | Labels | What it answers |
154
+ | --- | --- | --- |
155
+ | `load_duration_seconds` / `load_total` | `outcome`: `success`, `empty`, `folder_failed`, `skipped_no_ids` | Is load slow? How often do we run without memory? |
156
+ | `postprocessor_duration_seconds` / `postprocessor_total` | `outcome`: `updated`, `noop`, `upload_failed`, `skipped_no_ids`, `error` | Did this turn persist a new profile? |
157
+ | `errors_total` | `stage`, `error_type` | Infra failures by stage (`folder_lookup`, `folder_create`, `download`, `upload`, `gate`, `consolidation`, `scrub`, `condense`, `postprocessor`). |
158
+ | `llm_duration_seconds` / `llm_errors_total` | `purpose`: `gate`, `consolidation`, `scrub`, `condense`; `model`: the configured model name | Which LLM call is the latency or error hog, and how does that differ by model? |
159
+ | `gate_decisions_total` | `decision`: `update`, `noop`, `fail_open` | Is the gate saving consolidations? Is fail-open spiking? |
160
+ | `scrub_decisions_total` | `decision`: `clean`, `cleaned`, `veto` | CID/PII pass health. |
161
+ | `consolidation_results_total` | `result`: `rewritten`, `noop`, `malformed`, `llm_error`, `scrub_veto`, `unchanged_after_scrub` | Why a rewrite did not persist. |
162
+ | `storage_duration_seconds` / `storage_total` | `op`: `folder_lookup`, `folder_create`, `download`, `upload`; `outcome`: `success`, `error`, `not_found`, `refused_empty` | Is the content store the bottleneck? |
163
+ | `memory_length_tokens` / `memory_length_chars` | `phase`: `load`, `write` | How large are profiles in tokens and characters versus `max_tokens`? |
164
+ | `condense_total` | `trigger`: `load`, `post_consolidation`, `condense`; `result`: `llm_ok`, `hard_cut`, `llm_failed_then_hard_cut` | How often the budget shrink path fires. |
165
+
166
+ Token usage for billing still flows through `LanguageModelInvocationStats`, not these series.
167
+
133
168
  ## Integration
134
169
 
135
170
  Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.3
2
- Name: unique-user-memory
3
- Version: 2026.38.0.dev2
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.38.0.dev4,<2026.38.0rc0
11
- Requires-Dist: unique-toolkit>=2026.38.0.dev14,<2026.38.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.
@@ -144,6 +130,27 @@ config = UserMemoryConfig(
144
130
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
145
131
  | `root_folder` | `user-memory` | Subfolder name under each user's home folder (`/home-<user_id>/<root_folder>`) that holds the memory profile; also read as a legacy fallback at `/<root_folder>/<user_id>`. |
146
132
 
133
+ ## Observability
134
+
135
+ The package registers Prometheus metrics on the shared `unique_toolkit.monitoring` registry (namespace `unique_user_memory`). They appear on whatever host already scrapes `GET /metrics` (assistants-core). Labels are closed enums — no company or user ids. LLM series include the model name so latency can be split per deployed model.
136
+
137
+ QA dashboard: [User Memory](https://qa-grafana.alpine-bowfin.ts.net/d/unique-user-memory) (`unique-user-memory`). The dashboard is provisioned by the [assistants-core Helm chart](https://github.com/Unique-AG/monorepo/tree/master/python/assistants/bundles/core/deploy/helm-chart/files/grafana/dashboards).
138
+
139
+ | Metric | Labels | What it answers |
140
+ | --- | --- | --- |
141
+ | `load_duration_seconds` / `load_total` | `outcome`: `success`, `empty`, `folder_failed`, `skipped_no_ids` | Is load slow? How often do we run without memory? |
142
+ | `postprocessor_duration_seconds` / `postprocessor_total` | `outcome`: `updated`, `noop`, `upload_failed`, `skipped_no_ids`, `error` | Did this turn persist a new profile? |
143
+ | `errors_total` | `stage`, `error_type` | Infra failures by stage (`folder_lookup`, `folder_create`, `download`, `upload`, `gate`, `consolidation`, `scrub`, `condense`, `postprocessor`). |
144
+ | `llm_duration_seconds` / `llm_errors_total` | `purpose`: `gate`, `consolidation`, `scrub`, `condense`; `model`: the configured model name | Which LLM call is the latency or error hog, and how does that differ by model? |
145
+ | `gate_decisions_total` | `decision`: `update`, `noop`, `fail_open` | Is the gate saving consolidations? Is fail-open spiking? |
146
+ | `scrub_decisions_total` | `decision`: `clean`, `cleaned`, `veto` | CID/PII pass health. |
147
+ | `consolidation_results_total` | `result`: `rewritten`, `noop`, `malformed`, `llm_error`, `scrub_veto`, `unchanged_after_scrub` | Why a rewrite did not persist. |
148
+ | `storage_duration_seconds` / `storage_total` | `op`: `folder_lookup`, `folder_create`, `download`, `upload`; `outcome`: `success`, `error`, `not_found`, `refused_empty` | Is the content store the bottleneck? |
149
+ | `memory_length_tokens` / `memory_length_chars` | `phase`: `load`, `write` | How large are profiles in tokens and characters versus `max_tokens`? |
150
+ | `condense_total` | `trigger`: `load`, `post_consolidation`, `condense`; `result`: `llm_ok`, `hard_cut`, `llm_failed_then_hard_cut` | How often the budget shrink path fires. |
151
+
152
+ Token usage for billing still flows through `LanguageModelInvocationStats`, not these series.
153
+
147
154
  ## Integration
148
155
 
149
156
  Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_user_memory"
3
- version = "2026.38.0.dev2"
3
+ version = "2026.40.0.dev0"
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.38.0.dev4,<2026.38.0rc0",
15
- "unique-toolkit>=2026.38.0.dev14,<2026.38.0rc0",
14
+ "unique-sdk>=2026.40.0.dev0,<2026.40.0rc0",
15
+ "unique-toolkit[monitoring]>=2026.40.0.dev0,<2026.40.0rc0",
16
16
  ]
17
17
 
18
18
  [dependency-groups]
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from typing import Protocol
7
+
8
+ from unique_toolkit.monitoring import MetricNamespace
9
+
10
+
11
+ class _ObservesSeconds(Protocol):
12
+ def observe(self, amount: float) -> None: ...
13
+
14
+
15
+ m = MetricNamespace("unique_user_memory")
16
+
17
+ _DURATION_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 60.0)
18
+ _TOKEN_BUCKETS = (
19
+ 0.0,
20
+ 100.0,
21
+ 250.0,
22
+ 500.0,
23
+ 1000.0,
24
+ 1500.0,
25
+ 2000.0,
26
+ 3000.0,
27
+ 5000.0,
28
+ 8000.0,
29
+ )
30
+ _CHAR_BUCKETS = (
31
+ 0.0,
32
+ 200.0,
33
+ 500.0,
34
+ 1000.0,
35
+ 2000.0,
36
+ 4000.0,
37
+ 8000.0,
38
+ 16000.0,
39
+ 32000.0,
40
+ )
41
+
42
+ # End-to-end
43
+ load_duration = m.histogram(
44
+ "load_duration_seconds",
45
+ "load_user_memory() duration",
46
+ [],
47
+ buckets=_DURATION_BUCKETS,
48
+ )
49
+ load_total = m.counter(
50
+ "load_total",
51
+ "Memory load attempts by outcome",
52
+ ["outcome"],
53
+ )
54
+
55
+ postprocessor_duration = m.histogram(
56
+ "postprocessor_duration_seconds",
57
+ "UserMemoryPostprocessor.run() duration",
58
+ [],
59
+ buckets=_DURATION_BUCKETS,
60
+ )
61
+ postprocessor_total = m.counter(
62
+ "postprocessor_total",
63
+ "Postprocessor runs by outcome",
64
+ ["outcome"],
65
+ )
66
+
67
+ errors = m.counter(
68
+ "errors_total",
69
+ "User memory infrastructure failures",
70
+ ["stage", "error_type"],
71
+ )
72
+
73
+ # LLM
74
+ llm_duration = m.histogram(
75
+ "llm_duration_seconds",
76
+ "User memory LLM call duration",
77
+ ["purpose", "model"],
78
+ buckets=_DURATION_BUCKETS,
79
+ )
80
+ llm_errors = m.counter(
81
+ "llm_errors_total",
82
+ "User memory LLM call failures",
83
+ ["purpose", "model", "error_type"],
84
+ )
85
+
86
+ # Decisions
87
+ gate_decisions = m.counter(
88
+ "gate_decisions_total",
89
+ "Consolidation gate decisions",
90
+ ["decision"],
91
+ )
92
+ scrub_decisions = m.counter(
93
+ "scrub_decisions_total",
94
+ "CID/PII scrub decisions",
95
+ ["decision"],
96
+ )
97
+ consolidation_results = m.counter(
98
+ "consolidation_results_total",
99
+ "Full-profile rewrite results",
100
+ ["result"],
101
+ )
102
+
103
+ # Storage
104
+ storage_duration = m.histogram(
105
+ "storage_duration_seconds",
106
+ "Folder and content store latency",
107
+ ["op"],
108
+ buckets=_DURATION_BUCKETS,
109
+ )
110
+ storage_total = m.counter(
111
+ "storage_total",
112
+ "Folder and content store calls",
113
+ ["op", "outcome"],
114
+ )
115
+
116
+ # Size / budget
117
+ memory_length_tokens = m.histogram(
118
+ "memory_length_tokens",
119
+ "Memory profile size in tokens",
120
+ ["phase"],
121
+ buckets=_TOKEN_BUCKETS,
122
+ )
123
+ memory_length_chars = m.histogram(
124
+ "memory_length_chars",
125
+ "Memory profile size in characters",
126
+ ["phase"],
127
+ buckets=_CHAR_BUCKETS,
128
+ )
129
+ condense_total = m.counter(
130
+ "condense_total",
131
+ "Over-budget condensation attempts",
132
+ ["trigger", "result"],
133
+ )
134
+
135
+
136
+ def record_error(stage: str, exc: BaseException) -> None:
137
+ errors.labels(stage=stage, error_type=type(exc).__name__).inc()
138
+
139
+
140
+ def record_load(outcome: str) -> None:
141
+ load_total.labels(outcome=outcome).inc()
142
+
143
+
144
+ def record_postprocessor(outcome: str) -> None:
145
+ postprocessor_total.labels(outcome=outcome).inc()
146
+
147
+
148
+ def record_gate(decision: str) -> None:
149
+ gate_decisions.labels(decision=decision).inc()
150
+
151
+
152
+ def record_scrub(decision: str) -> None:
153
+ scrub_decisions.labels(decision=decision).inc()
154
+
155
+
156
+ def record_consolidation(result: str) -> None:
157
+ consolidation_results.labels(result=result).inc()
158
+
159
+
160
+ def record_storage(op: str, outcome: str) -> None:
161
+ storage_total.labels(op=op, outcome=outcome).inc()
162
+
163
+
164
+ def record_condense(trigger: str, result: str) -> None:
165
+ condense_total.labels(trigger=trigger, result=result).inc()
166
+
167
+
168
+ def model_label(model_name: object) -> str:
169
+ """Stable Prometheus label for a language-model name (enum or string)."""
170
+ value = getattr(model_name, "value", model_name)
171
+ return str(value)
172
+
173
+
174
+ def observe_memory_length(*, phase: str, content: str, tokens: float) -> None:
175
+ memory_length_tokens.labels(phase=phase).observe(tokens)
176
+ memory_length_chars.labels(phase=phase).observe(len(content))
177
+
178
+
179
+ def condense_trigger(invocation_source: str) -> str:
180
+ if invocation_source == "user_memory_load_condense":
181
+ return "load"
182
+ if invocation_source == "user_memory_post_consolidation_condense":
183
+ return "post_consolidation"
184
+ return "condense"
185
+
186
+
187
+ @contextmanager
188
+ def observe_unlabeled(histogram: _ObservesSeconds) -> Iterator[None]:
189
+ """Time a block on a histogram that has no labels.
190
+
191
+ ``metric_scope`` always calls ``labels()``, which prometheus_client
192
+ rejects when the instrument was declared with an empty label set.
193
+ """
194
+ start = time.perf_counter()
195
+ try:
196
+ yield
197
+ finally:
198
+ histogram.observe(time.perf_counter() - start)
199
+
200
+
201
+ __all__ = [
202
+ "condense_total",
203
+ "condense_trigger",
204
+ "consolidation_results",
205
+ "errors",
206
+ "gate_decisions",
207
+ "llm_duration",
208
+ "llm_errors",
209
+ "load_duration",
210
+ "load_total",
211
+ "memory_length_chars",
212
+ "memory_length_tokens",
213
+ "model_label",
214
+ "observe_memory_length",
215
+ "observe_unlabeled",
216
+ "postprocessor_duration",
217
+ "postprocessor_total",
218
+ "record_condense",
219
+ "record_consolidation",
220
+ "record_error",
221
+ "record_gate",
222
+ "record_load",
223
+ "record_postprocessor",
224
+ "record_scrub",
225
+ "record_storage",
226
+ "scrub_decisions",
227
+ "storage_duration",
228
+ "storage_total",
229
+ ]