unique-user-memory 2026.38.0.dev1__tar.gz → 2026.38.0.dev3__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.38.0.dev3
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[monitoring]>=2026.38.0.dev14,<2026.38.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.
@@ -25,7 +39,38 @@ The memory file is intentionally small and structured. It is rewritten as a full
25
39
  5. `UserMemoryPostprocessor` runs after the assistant response.
26
40
  6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
27
41
  7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
28
- 8. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
42
+ 8. Every rewrite passes through a mandatory CID/PII scrub call (see Content Policy below) before it is assembled for upload. When the scrub cannot vouch for the rewrite, the existing memory is kept unchanged.
43
+ 9. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
44
+
45
+ ## Content Policy — the current user's CID/PII only (UN-24886)
46
+
47
+ The profile belongs to exactly one person: the signed-in user. It may contain
48
+ personal data (PII) of that user only. It must never contain
49
+ client-identifying data (CID) or PII of any other person or private entity —
50
+ not other users, not clients, not prospects, not counterparties. Facts about
51
+ the user's own work that involve a client are stored only in a fully
52
+ de-identified form ("runs quarterly portfolio reviews", never "reviews the
53
+ portfolio of J. Muster"). Credentials, payment data, health records, and
54
+ government IDs are never stored for anyone, including the user.
55
+
56
+ The policy text lives once, in `_CID_POLICY` in `user_memory_prompts.py`, and
57
+ is embedded in every write-path stage so the rules cannot drift:
58
+
59
+ | Stage | Role of the policy |
60
+ | --- | --- |
61
+ | Gate (`memory_gate_system_prompt`) | Turns whose only new facts are third-party CID/PII lean `NOOP` and never reach the rewrite. |
62
+ | Consolidation (`consolidation_system_prompt`) | The rewrite itself must not extract forbidden content. |
63
+ | Condensation (`condensation_system_prompt`) | Oversized (including legacy) profiles must drop violating bullets on every shrink, progressively cleaning old data. |
64
+ | Scrub (`scrub_system_prompt` / `scrub_user_memory`) | Mandatory final gate on every consolidation rewrite. Answers `CLEAN`, returns a cleaned body, or vetoes the write. Fails closed: on any error the existing memory is kept and the unvalidated candidate is discarded. |
65
+
66
+ Storage-level isolation (per-user home folder with exclusive owner ACL, see
67
+ Storage Model) already prevents cross-user *access*; this policy governs
68
+ cross-user and cross-client *content*.
69
+
70
+ Note for follow-up client-memory work: this package is user memory only. A
71
+ future client memory is a separate artefact with its own scoping and must not
72
+ be built by loosening this policy — client-related durable knowledge is
73
+ intentionally rejected here rather than stored under the user's profile.
29
74
 
30
75
  ## Storage Model
31
76
 
@@ -99,6 +144,27 @@ config = UserMemoryConfig(
99
144
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
100
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>`. |
101
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
+
102
168
  ## Integration
103
169
 
104
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.dev1
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.dev1,<2026.38.0rc0
11
- Requires-Dist: unique-toolkit>=2026.38.0.dev5,<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.
@@ -39,7 +25,38 @@ The memory file is intentionally small and structured. It is rewritten as a full
39
25
  5. `UserMemoryPostprocessor` runs after the assistant response.
40
26
  6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
41
27
  7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
42
- 8. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
28
+ 8. Every rewrite passes through a mandatory CID/PII scrub call (see Content Policy below) before it is assembled for upload. When the scrub cannot vouch for the rewrite, the existing memory is kept unchanged.
29
+ 9. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
30
+
31
+ ## Content Policy — the current user's CID/PII only (UN-24886)
32
+
33
+ The profile belongs to exactly one person: the signed-in user. It may contain
34
+ personal data (PII) of that user only. It must never contain
35
+ client-identifying data (CID) or PII of any other person or private entity —
36
+ not other users, not clients, not prospects, not counterparties. Facts about
37
+ the user's own work that involve a client are stored only in a fully
38
+ de-identified form ("runs quarterly portfolio reviews", never "reviews the
39
+ portfolio of J. Muster"). Credentials, payment data, health records, and
40
+ government IDs are never stored for anyone, including the user.
41
+
42
+ The policy text lives once, in `_CID_POLICY` in `user_memory_prompts.py`, and
43
+ is embedded in every write-path stage so the rules cannot drift:
44
+
45
+ | Stage | Role of the policy |
46
+ | --- | --- |
47
+ | Gate (`memory_gate_system_prompt`) | Turns whose only new facts are third-party CID/PII lean `NOOP` and never reach the rewrite. |
48
+ | Consolidation (`consolidation_system_prompt`) | The rewrite itself must not extract forbidden content. |
49
+ | Condensation (`condensation_system_prompt`) | Oversized (including legacy) profiles must drop violating bullets on every shrink, progressively cleaning old data. |
50
+ | Scrub (`scrub_system_prompt` / `scrub_user_memory`) | Mandatory final gate on every consolidation rewrite. Answers `CLEAN`, returns a cleaned body, or vetoes the write. Fails closed: on any error the existing memory is kept and the unvalidated candidate is discarded. |
51
+
52
+ Storage-level isolation (per-user home folder with exclusive owner ACL, see
53
+ Storage Model) already prevents cross-user *access*; this policy governs
54
+ cross-user and cross-client *content*.
55
+
56
+ Note for follow-up client-memory work: this package is user memory only. A
57
+ future client memory is a separate artefact with its own scoping and must not
58
+ be built by loosening this policy — client-related durable knowledge is
59
+ intentionally rejected here rather than stored under the user's profile.
43
60
 
44
61
  ## Storage Model
45
62
 
@@ -113,6 +130,27 @@ config = UserMemoryConfig(
113
130
  | `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
114
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>`. |
115
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
+
116
154
  ## Integration
117
155
 
118
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.dev1"
3
+ version = "2026.38.0.dev3"
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.dev1,<2026.38.0rc0",
15
- "unique-toolkit>=2026.38.0.dev5,<2026.38.0rc0",
14
+ "unique-sdk>=2026.38.0.dev4,<2026.38.0rc0",
15
+ "unique-toolkit[monitoring]>=2026.38.0.dev14,<2026.38.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
+ ]