unique-user-memory 2026.30.0.dev2__tar.gz → 2026.32.0.dev1__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.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/PKG-INFO +5 -5
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/README.md +2 -2
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/pyproject.toml +3 -3
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/tests/test_user_memory.py +128 -4
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/user_memory.py +54 -19
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/user_memory_prompts.py +21 -34
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/config.py +0 -0
- {unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/user_memory_postprocessor.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-user-memory
|
|
3
|
-
Version: 2026.
|
|
3
|
+
Version: 2026.32.0.dev1
|
|
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.
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.
|
|
10
|
+
Requires-Dist: unique-sdk>=2026.32.0.dev0,<2026.32.0rc0
|
|
11
|
+
Requires-Dist: unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0
|
|
12
12
|
Requires-Python: >=3.12, <4
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
Persistent per-user memory for Unique AI agents.
|
|
18
18
|
|
|
19
|
-
`unique_user_memory` stores a compact Markdown profile for each user and updates it after every agent turn. The profile is loaded before the next turn so the assistant can remember stable user context such as communication preferences, work context, expertise, recent topics, and
|
|
19
|
+
`unique_user_memory` stores a compact Markdown profile for each user and updates it after every agent turn. The profile is loaded before the next turn so the assistant can remember stable user context such as communication preferences, work context, expertise, recent topics, and concrete future tasks.
|
|
20
20
|
|
|
21
21
|
## What It Does
|
|
22
22
|
|
|
@@ -76,7 +76,7 @@ _(empty)_
|
|
|
76
76
|
## Recent Topics
|
|
77
77
|
_(empty)_
|
|
78
78
|
|
|
79
|
-
##
|
|
79
|
+
## Follow-ups
|
|
80
80
|
_(empty)_
|
|
81
81
|
```
|
|
82
82
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Persistent per-user memory for Unique AI agents.
|
|
4
4
|
|
|
5
|
-
`unique_user_memory` stores a compact Markdown profile for each user and updates it after every agent turn. The profile is loaded before the next turn so the assistant can remember stable user context such as communication preferences, work context, expertise, recent topics, and
|
|
5
|
+
`unique_user_memory` stores a compact Markdown profile for each user and updates it after every agent turn. The profile is loaded before the next turn so the assistant can remember stable user context such as communication preferences, work context, expertise, recent topics, and concrete future tasks.
|
|
6
6
|
|
|
7
7
|
## What It Does
|
|
8
8
|
|
|
@@ -62,7 +62,7 @@ _(empty)_
|
|
|
62
62
|
## Recent Topics
|
|
63
63
|
_(empty)_
|
|
64
64
|
|
|
65
|
-
##
|
|
65
|
+
## Follow-ups
|
|
66
66
|
_(empty)_
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_user_memory"
|
|
3
|
-
version = "2026.
|
|
3
|
+
version = "2026.32.0.dev1"
|
|
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.
|
|
15
|
-
"unique-toolkit>=2026.
|
|
14
|
+
"unique-sdk>=2026.32.0.dev0,<2026.32.0rc0",
|
|
15
|
+
"unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[dependency-groups]
|
|
@@ -23,11 +23,26 @@ from unique_user_memory.user_memory import (
|
|
|
23
23
|
upload_user_memory,
|
|
24
24
|
)
|
|
25
25
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
26
|
-
from unique_user_memory.user_memory_prompts import
|
|
26
|
+
from unique_user_memory.user_memory_prompts import (
|
|
27
|
+
consolidation_system_prompt,
|
|
28
|
+
empty_profile,
|
|
29
|
+
memory_gate_system_prompt,
|
|
30
|
+
)
|
|
27
31
|
|
|
28
32
|
_TEST_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL)
|
|
29
33
|
|
|
30
34
|
|
|
35
|
+
def test_memory_profile_keeps_follow_up_tasks_but_excludes_open_questions() -> None:
|
|
36
|
+
profile = empty_profile("user_1")
|
|
37
|
+
consolidation_prompt = consolidation_system_prompt(2000)
|
|
38
|
+
gate_prompt = memory_gate_system_prompt()
|
|
39
|
+
|
|
40
|
+
assert "## Open Questions / Follow-ups" not in profile
|
|
41
|
+
assert "## Follow-ups" in profile
|
|
42
|
+
assert "concrete tasks the user intends to complete" in consolidation_prompt
|
|
43
|
+
assert "Concrete future tasks the user intends to complete" in gate_prompt
|
|
44
|
+
|
|
45
|
+
|
|
31
46
|
def test_enforce_token_cap_truncates_long_content() -> None:
|
|
32
47
|
content = "\n\n".join(f"paragraph {index} " + "word " * 40 for index in range(50))
|
|
33
48
|
|
|
@@ -144,6 +159,35 @@ async def test_condense_user_memory_rejects_non_profile_output(
|
|
|
144
159
|
assert result is None
|
|
145
160
|
|
|
146
161
|
|
|
162
|
+
@pytest.mark.ai
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_condense_user_memory_accepts_frontmatter_output(
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Accept legacy LLM output while returning only the condensed profile body."""
|
|
168
|
+
condensed = "# User Memory\n\n## Identity\n- concise summary"
|
|
169
|
+
response = MagicMock()
|
|
170
|
+
response.choices[0].message.content = (
|
|
171
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n" + condensed
|
|
172
|
+
)
|
|
173
|
+
llm_service = MagicMock()
|
|
174
|
+
llm_service.complete_async = AsyncMock(return_value=response)
|
|
175
|
+
monkeypatch.setattr(
|
|
176
|
+
"unique_user_memory.user_memory.LanguageModelService",
|
|
177
|
+
MagicMock(return_value=llm_service),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
result = await condense_user_memory(
|
|
181
|
+
content="# User Memory\n\n## Identity\n- lots of stuff",
|
|
182
|
+
max_tokens=2000,
|
|
183
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
184
|
+
event=MagicMock(),
|
|
185
|
+
logger=MagicMock(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert result == condensed
|
|
189
|
+
|
|
190
|
+
|
|
147
191
|
def test_count_tokens_uses_language_model_encoder() -> None:
|
|
148
192
|
language_model = MagicMock()
|
|
149
193
|
language_model.get_encoder.return_value = lambda content: content.split()
|
|
@@ -281,10 +325,86 @@ async def test_consolidate_user_memory_runs_full_rewrite_when_gate_update(
|
|
|
281
325
|
logger=MagicMock(),
|
|
282
326
|
)
|
|
283
327
|
|
|
284
|
-
assert result
|
|
328
|
+
assert result.endswith(f"{rewritten}\n")
|
|
329
|
+
assert "user_id: user_1" in result
|
|
330
|
+
assert "schema_version: 1" in result
|
|
331
|
+
assert "turn_count: 1" in result
|
|
285
332
|
llm_service.complete_async.assert_awaited_once()
|
|
286
333
|
|
|
287
334
|
|
|
335
|
+
@pytest.mark.asyncio
|
|
336
|
+
async def test_consolidate_user_memory_adds_frontmatter_to_llm_body(
|
|
337
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
338
|
+
) -> None:
|
|
339
|
+
rewritten = "# User Memory\n\n## Identity\n- Prefers concise answers"
|
|
340
|
+
response = MagicMock()
|
|
341
|
+
response.choices[0].message.content = rewritten
|
|
342
|
+
llm_service = MagicMock()
|
|
343
|
+
llm_service.complete_async = AsyncMock(return_value=response)
|
|
344
|
+
monkeypatch.setattr(
|
|
345
|
+
"unique_user_memory.user_memory.LanguageModelService",
|
|
346
|
+
MagicMock(return_value=llm_service),
|
|
347
|
+
)
|
|
348
|
+
monkeypatch.setattr(
|
|
349
|
+
"unique_user_memory.user_memory.should_consolidate_memory",
|
|
350
|
+
AsyncMock(return_value=True),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
result = await consolidate_user_memory(
|
|
354
|
+
current_memory="",
|
|
355
|
+
user_id="authenticated-user",
|
|
356
|
+
user_message="remember I like concise answers",
|
|
357
|
+
assistant_message="noted",
|
|
358
|
+
config=UserMemoryConfig(),
|
|
359
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
360
|
+
event=MagicMock(),
|
|
361
|
+
logger=MagicMock(),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
assert "user_id: authenticated-user" in result
|
|
365
|
+
assert "schema_version: 1" in result
|
|
366
|
+
assert "turn_count: 1" in result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@pytest.mark.ai
|
|
370
|
+
@pytest.mark.asyncio
|
|
371
|
+
async def test_consolidate_user_memory_replaces_llm_frontmatter(
|
|
372
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Strip untrusted legacy metadata before assembling the updated profile."""
|
|
375
|
+
rewritten = "# User Memory\n\n## Identity\n- Prefers concise answers"
|
|
376
|
+
response = MagicMock()
|
|
377
|
+
response.choices[0].message.content = (
|
|
378
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n" + rewritten
|
|
379
|
+
)
|
|
380
|
+
llm_service = MagicMock()
|
|
381
|
+
llm_service.complete_async = AsyncMock(return_value=response)
|
|
382
|
+
monkeypatch.setattr(
|
|
383
|
+
"unique_user_memory.user_memory.LanguageModelService",
|
|
384
|
+
MagicMock(return_value=llm_service),
|
|
385
|
+
)
|
|
386
|
+
monkeypatch.setattr(
|
|
387
|
+
"unique_user_memory.user_memory.should_consolidate_memory",
|
|
388
|
+
AsyncMock(return_value=True),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
result = await consolidate_user_memory(
|
|
392
|
+
current_memory=empty_profile("authenticated-user"),
|
|
393
|
+
user_id="authenticated-user",
|
|
394
|
+
user_message="remember I like concise answers",
|
|
395
|
+
assistant_message="noted",
|
|
396
|
+
config=UserMemoryConfig(),
|
|
397
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
398
|
+
event=MagicMock(),
|
|
399
|
+
logger=MagicMock(),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
assert result.endswith(f"{rewritten}\n")
|
|
403
|
+
assert "user_id: authenticated-user" in result
|
|
404
|
+
assert "stale-user" not in result
|
|
405
|
+
assert "turn_count: 1" in result
|
|
406
|
+
|
|
407
|
+
|
|
288
408
|
@pytest.mark.asyncio
|
|
289
409
|
async def test_consolidate_user_memory_skips_gate_when_disabled(
|
|
290
410
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -316,7 +436,9 @@ async def test_consolidate_user_memory_skips_gate_when_disabled(
|
|
|
316
436
|
logger=MagicMock(),
|
|
317
437
|
)
|
|
318
438
|
|
|
319
|
-
assert result
|
|
439
|
+
assert result.endswith(f"{rewritten}\n")
|
|
440
|
+
assert "user_id: user_1" in result
|
|
441
|
+
assert "turn_count: 1" in result
|
|
320
442
|
gate.assert_not_awaited()
|
|
321
443
|
llm_service.complete_async.assert_awaited_once()
|
|
322
444
|
|
|
@@ -434,7 +556,9 @@ async def test_consolidate_user_memory_invokes_update_callbacks_on_rewrite(
|
|
|
434
556
|
on_update_end=on_end,
|
|
435
557
|
)
|
|
436
558
|
|
|
437
|
-
assert result
|
|
559
|
+
assert result.endswith(f"{rewritten}\n")
|
|
560
|
+
assert "user_id: user_1" in result
|
|
561
|
+
assert "turn_count: 1" in result
|
|
438
562
|
on_start.assert_awaited_once()
|
|
439
563
|
on_end.assert_awaited_once()
|
|
440
564
|
assert events == ["start", "end"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from collections.abc import Awaitable, Callable
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
4
5
|
from logging import Logger
|
|
5
6
|
|
|
6
7
|
import unique_sdk
|
|
@@ -56,6 +57,40 @@ _CONDENSE_TARGET_RATIO = 0.9
|
|
|
56
57
|
_TRUNCATION_MARKER = "\n\n<!-- truncated to fit memory budget -->"
|
|
57
58
|
_DEFAULT_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_GPT_4o)
|
|
58
59
|
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
|
60
|
+
_TURN_COUNT_RE = re.compile(r"^turn_count:\s*(\d+)\s*$", re.MULTILINE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _profile_body(content: str) -> str:
|
|
64
|
+
return _FRONTMATTER_RE.sub("", content, count=1).strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _turn_count(content: str) -> int:
|
|
68
|
+
frontmatter_match = _FRONTMATTER_RE.match(content)
|
|
69
|
+
if frontmatter_match is None:
|
|
70
|
+
return 0
|
|
71
|
+
match = _TURN_COUNT_RE.search(frontmatter_match.group(0))
|
|
72
|
+
return int(match.group(1)) if match else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _assemble_profile(*, body: str, user_id: str, turn_count: int) -> str:
|
|
76
|
+
"""Prefix an LLM-generated body with trusted application metadata."""
|
|
77
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
78
|
+
return (
|
|
79
|
+
"---\n"
|
|
80
|
+
f"user_id: {user_id}\n"
|
|
81
|
+
"schema_version: 1\n"
|
|
82
|
+
f"last_updated: {timestamp}\n"
|
|
83
|
+
f"turn_count: {turn_count}\n"
|
|
84
|
+
"---\n\n"
|
|
85
|
+
f"{body.strip()}\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _restore_frontmatter(original: str, body: str) -> str:
|
|
90
|
+
match = _FRONTMATTER_RE.match(original)
|
|
91
|
+
if match is None:
|
|
92
|
+
return body
|
|
93
|
+
return f"{match.group(0).rstrip()}\n\n{body.strip()}\n"
|
|
59
94
|
|
|
60
95
|
|
|
61
96
|
@dataclass(frozen=True)
|
|
@@ -154,7 +189,8 @@ async def condense_user_memory(
|
|
|
154
189
|
condensed profile, or ``None`` when the call fails or the output does
|
|
155
190
|
not look like a profile (the caller then falls back to a hard cut).
|
|
156
191
|
"""
|
|
157
|
-
|
|
192
|
+
body = _profile_body(content)
|
|
193
|
+
current_tokens = count_tokens(content=body, language_model=language_model)
|
|
158
194
|
target_tokens = max(1, int(max_tokens * _CONDENSE_TARGET_RATIO))
|
|
159
195
|
|
|
160
196
|
try:
|
|
@@ -177,7 +213,7 @@ async def condense_user_memory(
|
|
|
177
213
|
)
|
|
178
214
|
),
|
|
179
215
|
LanguageModelUserMessage(
|
|
180
|
-
content=condensation_user_prompt(_sanitize_for_xml_context(
|
|
216
|
+
content=condensation_user_prompt(_sanitize_for_xml_context(body))
|
|
181
217
|
),
|
|
182
218
|
]
|
|
183
219
|
)
|
|
@@ -214,7 +250,7 @@ async def condense_user_memory(
|
|
|
214
250
|
)
|
|
215
251
|
return None
|
|
216
252
|
|
|
217
|
-
candidate = _strip_code_fences(raw)
|
|
253
|
+
candidate = _profile_body(_strip_code_fences(raw))
|
|
218
254
|
if not _is_well_formed_profile(candidate):
|
|
219
255
|
logger.warning(
|
|
220
256
|
"[user-memory] condense output did not look like a profile (%d chars)",
|
|
@@ -259,6 +295,7 @@ async def fit_user_memory(
|
|
|
259
295
|
logger=logger,
|
|
260
296
|
)
|
|
261
297
|
if condensed is not None:
|
|
298
|
+
condensed = _restore_frontmatter(content, condensed)
|
|
262
299
|
condensed_tokens = count_tokens(
|
|
263
300
|
content=condensed, language_model=language_model
|
|
264
301
|
)
|
|
@@ -752,8 +789,7 @@ async def _rewrite_user_memory(
|
|
|
752
789
|
),
|
|
753
790
|
LanguageModelUserMessage(
|
|
754
791
|
content=consolidation_user_prompt(
|
|
755
|
-
|
|
756
|
-
existing_memory=safe_current,
|
|
792
|
+
existing_memory=_profile_body(safe_current),
|
|
757
793
|
user_message=_sanitize_for_xml_context(user_message or ""),
|
|
758
794
|
assistant_message=_sanitize_for_xml_context(
|
|
759
795
|
assistant_message or ""
|
|
@@ -801,14 +837,23 @@ async def _rewrite_user_memory(
|
|
|
801
837
|
logger.info("[user-memory] consolidation NOOP - keeping existing memory")
|
|
802
838
|
return safe_current
|
|
803
839
|
|
|
804
|
-
|
|
805
|
-
if not _is_well_formed_profile(
|
|
840
|
+
candidate_body = _profile_body(_strip_code_fences(raw))
|
|
841
|
+
if not _is_well_formed_profile(candidate_body):
|
|
806
842
|
logger.warning(
|
|
807
843
|
"[user-memory] LLM output did not look like a profile (%d chars)",
|
|
808
|
-
len(
|
|
844
|
+
len(candidate_body),
|
|
809
845
|
)
|
|
810
846
|
return safe_current
|
|
811
847
|
|
|
848
|
+
if safe_current and candidate_body == _profile_body(safe_current):
|
|
849
|
+
logger.debug("[user-memory] memory body unchanged - skipping update")
|
|
850
|
+
return safe_current
|
|
851
|
+
|
|
852
|
+
candidate = _assemble_profile(
|
|
853
|
+
body=candidate_body,
|
|
854
|
+
user_id=user_id,
|
|
855
|
+
turn_count=_turn_count(safe_current) + 1,
|
|
856
|
+
)
|
|
812
857
|
capped = await fit_user_memory(
|
|
813
858
|
content=candidate,
|
|
814
859
|
max_tokens=config.max_tokens,
|
|
@@ -816,14 +861,6 @@ async def _rewrite_user_memory(
|
|
|
816
861
|
event=event,
|
|
817
862
|
logger=logger,
|
|
818
863
|
)
|
|
819
|
-
if (
|
|
820
|
-
safe_current
|
|
821
|
-
and _FRONTMATTER_RE.sub("", capped).strip()
|
|
822
|
-
== _FRONTMATTER_RE.sub("", safe_current).strip()
|
|
823
|
-
):
|
|
824
|
-
logger.debug("[user-memory] memory body unchanged - skipping update")
|
|
825
|
-
return safe_current
|
|
826
|
-
|
|
827
864
|
logger.info(
|
|
828
865
|
"[user-memory] consolidation produced %d tokens (cap=%d)",
|
|
829
866
|
count_tokens(content=capped, language_model=language_model),
|
|
@@ -839,9 +876,7 @@ def _sanitize_for_xml_context(text: str) -> str:
|
|
|
839
876
|
def _is_well_formed_profile(content: str) -> bool:
|
|
840
877
|
if not content or len(content.strip()) < 20:
|
|
841
878
|
return False
|
|
842
|
-
|
|
843
|
-
return True
|
|
844
|
-
return "## Identity" in content or "# User Memory" in content
|
|
879
|
+
return content.startswith("# User Memory") and "## Identity" in content
|
|
845
880
|
|
|
846
881
|
|
|
847
882
|
def _strip_code_fences(text: str) -> str:
|
|
@@ -8,7 +8,7 @@ SECTION_HEADINGS: tuple[str, ...] = (
|
|
|
8
8
|
"Work Context",
|
|
9
9
|
"Skills & Expertise",
|
|
10
10
|
"Recent Topics",
|
|
11
|
-
"
|
|
11
|
+
"Follow-ups",
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
_EMPTY_PROFILE_TEMPLATE = """\
|
|
@@ -52,16 +52,16 @@ small mistakes in extraction compound across every future conversation.
|
|
|
52
52
|
|
|
53
53
|
You receive two XML blocks:
|
|
54
54
|
|
|
55
|
-
1. `<existing_memory>` - the current profile
|
|
56
|
-
|
|
55
|
+
1. `<existing_memory>` - the current Markdown profile body. May be empty
|
|
56
|
+
on the user's first turn.
|
|
57
57
|
2. `<new_turn>` - the most recent user message and the assistant's
|
|
58
58
|
reply, prefixed with `user:` and `assistant:`.
|
|
59
59
|
|
|
60
60
|
# Output
|
|
61
61
|
|
|
62
|
-
Return the complete, rewritten profile
|
|
63
|
-
|
|
64
|
-
``` fences. Do NOT add commentary before or after the
|
|
62
|
+
Return the complete, rewritten Markdown profile body, starting with
|
|
63
|
+
`# User Memory`. Do NOT emit a diff. Do NOT wrap the output in
|
|
64
|
+
``` fences. Do NOT add commentary before or after the body.
|
|
65
65
|
|
|
66
66
|
The body MUST contain exactly these section headings, in this order, even
|
|
67
67
|
when a section is empty (use the literal string `_(empty)_` as a placeholder):
|
|
@@ -119,8 +119,8 @@ ADD/UPDATE for facts that are:
|
|
|
119
119
|
tone, language, expertise level, examples preferred over theory.
|
|
120
120
|
- Contextual but durable - current focus areas, active projects,
|
|
121
121
|
multi-week goals, deadlines mentioned by the user.
|
|
122
|
-
-
|
|
123
|
-
|
|
122
|
+
- Follow-ups - concrete tasks the user intends to complete in the future,
|
|
123
|
+
or tasks they explicitly ask to be reminded about.
|
|
124
124
|
|
|
125
125
|
NEVER extract:
|
|
126
126
|
|
|
@@ -135,11 +135,11 @@ NEVER extract:
|
|
|
135
135
|
|
|
136
136
|
# Word budget - STRICT
|
|
137
137
|
|
|
138
|
-
The complete
|
|
138
|
+
The complete body MUST be <= {{ max_words }} words (corresponding to {{ max_tokens }} tokens).
|
|
139
139
|
When approaching the budget, drop content in this priority order:
|
|
140
140
|
|
|
141
141
|
1. Oldest entries in Recent Topics.
|
|
142
|
-
2.
|
|
142
|
+
2. Completed, cancelled, or stale entries in Follow-ups.
|
|
143
143
|
3. Fold low-signal Work Context bullets into a one-line summary.
|
|
144
144
|
4. Fold low-signal Skills & Expertise bullets into broader categories.
|
|
145
145
|
5. Identity and Communication Preferences - never drop, only tighten.
|
|
@@ -148,14 +148,6 @@ When approaching the budget, drop content in this priority order:
|
|
|
148
148
|
|
|
149
149
|
The current UTC date and time is **{{ now_datetime }}**. You do NOT know the date from any other source - always use this supplied value. Never guess or infer the date.
|
|
150
150
|
|
|
151
|
-
# Frontmatter rules
|
|
152
|
-
|
|
153
|
-
- Preserve `user_id` and `schema_version` from `<existing_memory>` exactly.
|
|
154
|
-
- Set `last_updated` to the supplied current UTC timestamp ({{ now_datetime }}).
|
|
155
|
-
- Increment `turn_count` by 1.
|
|
156
|
-
- If `<existing_memory>` is empty, initialize with `schema_version: 1`,
|
|
157
|
-
`turn_count: 1`, and the user_id supplied in the user message.
|
|
158
|
-
|
|
159
151
|
# Style
|
|
160
152
|
|
|
161
153
|
- Use `-` markdown bullets, no nesting beyond two levels.
|
|
@@ -180,8 +172,8 @@ def consolidation_system_prompt(max_tokens: int) -> str:
|
|
|
180
172
|
_CONDENSATION_SYSTEM_PROMPT_TEMPLATE = """\
|
|
181
173
|
You are a memory-compaction engine for the Unique AI platform.
|
|
182
174
|
|
|
183
|
-
You are given an existing user-memory
|
|
184
|
-
|
|
175
|
+
You are given an existing user-memory Markdown body that is OVER its
|
|
176
|
+
size budget. Your job is to rewrite it so
|
|
185
177
|
it becomes materially SHORTER while preserving every durable, high-signal
|
|
186
178
|
fact about the user. This is lossy compression, not deletion of meaning.
|
|
187
179
|
|
|
@@ -198,8 +190,8 @@ fact about the user. This is lossy compression, not deletion of meaning.
|
|
|
198
190
|
overlapping information into a single clear bullet. Redundancy is the
|
|
199
191
|
main reason this profile is oversized - collapse it aggressively.
|
|
200
192
|
2. Delete outdated, stale, resolved, or superseded entries: old
|
|
201
|
-
"Recent Topics",
|
|
202
|
-
|
|
193
|
+
"Recent Topics", completed or cancelled "Follow-ups", and facts a later
|
|
194
|
+
bullet already contradicts or refines.
|
|
203
195
|
3. Tighten verbose, flowery, or repetitive prose into short factual
|
|
204
196
|
bullets. Remove hedging and filler.
|
|
205
197
|
4. Fold low-signal "Work Context" and "Skills & Expertise" bullets into
|
|
@@ -211,8 +203,6 @@ fact about the user. This is lossy compression, not deletion of meaning.
|
|
|
211
203
|
# Hard rules
|
|
212
204
|
|
|
213
205
|
- 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
206
|
- Keep exactly these section headings, in this order, even if a section
|
|
217
207
|
becomes empty (use the literal string `_(empty)_`):
|
|
218
208
|
|
|
@@ -224,9 +214,9 @@ fact about the user. This is lossy compression, not deletion of meaning.
|
|
|
224
214
|
|
|
225
215
|
# Output
|
|
226
216
|
|
|
227
|
-
Return
|
|
228
|
-
|
|
229
|
-
do NOT
|
|
217
|
+
Return the complete rewritten profile body, starting with
|
|
218
|
+
`# User Memory`. Do NOT emit a diff, or commentary,
|
|
219
|
+
and do NOT wrap the output in ``` fences.
|
|
230
220
|
"""
|
|
231
221
|
|
|
232
222
|
|
|
@@ -254,7 +244,7 @@ _CONDENSATION_USER_PROMPT_TEMPLATE = """\
|
|
|
254
244
|
{{ profile }}
|
|
255
245
|
</profile_to_condense>
|
|
256
246
|
|
|
257
|
-
Return the complete, condensed profile
|
|
247
|
+
Return the complete, condensed profile body now.
|
|
258
248
|
"""
|
|
259
249
|
|
|
260
250
|
|
|
@@ -263,8 +253,6 @@ def condensation_user_prompt(profile: str) -> str:
|
|
|
263
253
|
|
|
264
254
|
|
|
265
255
|
_CONSOLIDATION_USER_PROMPT_TEMPLATE = """\
|
|
266
|
-
User ID: {{ user_id }}
|
|
267
|
-
|
|
268
256
|
<existing_memory>
|
|
269
257
|
{{ existing_memory }}
|
|
270
258
|
</existing_memory>
|
|
@@ -274,19 +262,17 @@ user: {{ user_message }}
|
|
|
274
262
|
assistant: {{ assistant_message }}
|
|
275
263
|
</new_turn>
|
|
276
264
|
|
|
277
|
-
Return the complete rewritten profile
|
|
265
|
+
Return the complete rewritten profile body now.
|
|
278
266
|
"""
|
|
279
267
|
|
|
280
268
|
|
|
281
269
|
def consolidation_user_prompt(
|
|
282
|
-
user_id: str,
|
|
283
270
|
existing_memory: str,
|
|
284
271
|
user_message: str,
|
|
285
272
|
assistant_message: str,
|
|
286
273
|
) -> str:
|
|
287
274
|
existing = existing_memory.strip() or "(empty - this is the user's first turn)"
|
|
288
275
|
return Template(_CONSOLIDATION_USER_PROMPT_TEMPLATE).render(
|
|
289
|
-
user_id=user_id,
|
|
290
276
|
existing_memory=existing,
|
|
291
277
|
user_message=(user_message or "").strip(),
|
|
292
278
|
assistant_message=(assistant_message or "").strip(),
|
|
@@ -327,7 +313,8 @@ only the single word `UPDATE` or `NOOP`.
|
|
|
327
313
|
expertise level.
|
|
328
314
|
- Durable context: current focus areas, active projects, multi-week
|
|
329
315
|
goals, deadlines stated by the user.
|
|
330
|
-
-
|
|
316
|
+
- Concrete future tasks the user intends to complete or explicitly asks
|
|
317
|
+
to be reminded about.
|
|
331
318
|
|
|
332
319
|
# What NEVER justifies UPDATE (lean NOOP)
|
|
333
320
|
|
|
File without changes
|
{unique_user_memory-2026.30.0.dev2 → unique_user_memory-2026.32.0.dev1}/unique_user_memory/config.py
RENAMED
|
File without changes
|