unique-user-memory 2026.36.0.dev2__tar.gz → 2026.36.0.dev4__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.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/PKG-INFO +13 -8
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/README.md +10 -5
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/pyproject.toml +3 -3
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/config.py +8 -14
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/tests/test_user_memory.py +226 -85
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory.py +129 -91
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory_prompts.py +5 -6
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory_message_log.py +0 -0
- {unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/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.36.0.
|
|
3
|
+
Version: 2026.36.0.dev4
|
|
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.36.0.
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.36.0.
|
|
10
|
+
Requires-Dist: unique-sdk>=2026.36.0.dev5,<2026.36.0rc0
|
|
11
|
+
Requires-Dist: unique-toolkit>=2026.36.0.dev12,<2026.36.0rc0
|
|
12
12
|
Requires-Python: >=3.12, <4
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
@@ -33,7 +33,7 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
33
33
|
## Lifecycle
|
|
34
34
|
|
|
35
35
|
1. The orchestrator enables memory when `space.allow_user_memory` is true.
|
|
36
|
-
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the
|
|
36
|
+
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the user's private memory folder — canonical `/home-<user_id>/<root_folder>`, falling back to the legacy `/<root_folder>/<user_id>` leaf for not-yet-migrated users, provisioning the home folder if neither exists — and downloads `memory.md` from it if present.
|
|
37
37
|
3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
|
|
38
38
|
4. If memory was loaded, `profile_body(...)` of its text is passed into the agent context for the current turn — the prompt only gets the Markdown body, while the postprocessor keeps the full file because it needs the frontmatter to carry `turn_count` forward.
|
|
39
39
|
5. `UserMemoryPostprocessor` runs after the assistant response.
|
|
@@ -43,13 +43,18 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
43
43
|
|
|
44
44
|
## Storage Model
|
|
45
45
|
|
|
46
|
-
Memory is stored in Unique content as Markdown
|
|
46
|
+
Memory is stored in Unique content as Markdown, under each user's own
|
|
47
|
+
root-level home folder (UN-24823):
|
|
47
48
|
|
|
48
49
|
```text
|
|
49
|
-
|
|
50
|
+
/home-<user_id>/<root_folder>/memory.md
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
By default, `root_folder` is `user-memory`.
|
|
53
|
+
By default, `root_folder` is `user-memory`. There is no shared root folder —
|
|
54
|
+
each user's home is created by node-ingestion (or, if missing, by this
|
|
55
|
+
package) with an exclusive owner ACL, so memory is never company-writable
|
|
56
|
+
(UN-24764). Users whose memory has not yet been migrated to their home
|
|
57
|
+
folder are still read from the legacy location, `/<root_folder>/<user_id>/memory.md`.
|
|
53
58
|
|
|
54
59
|
## Profile Format
|
|
55
60
|
|
|
@@ -106,7 +111,7 @@ config = UserMemoryConfig(
|
|
|
106
111
|
| `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
|
|
107
112
|
| `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
|
|
108
113
|
| `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
|
|
109
|
-
| `root_folder` | `user-memory` |
|
|
114
|
+
| `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>`. |
|
|
110
115
|
|
|
111
116
|
## Integration
|
|
112
117
|
|
|
@@ -19,7 +19,7 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
19
19
|
## Lifecycle
|
|
20
20
|
|
|
21
21
|
1. The orchestrator enables memory when `space.allow_user_memory` is true.
|
|
22
|
-
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the
|
|
22
|
+
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the user's private memory folder — canonical `/home-<user_id>/<root_folder>`, falling back to the legacy `/<root_folder>/<user_id>` leaf for not-yet-migrated users, provisioning the home folder if neither exists — and downloads `memory.md` from it if present.
|
|
23
23
|
3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
|
|
24
24
|
4. If memory was loaded, `profile_body(...)` of its text is passed into the agent context for the current turn — the prompt only gets the Markdown body, while the postprocessor keeps the full file because it needs the frontmatter to carry `turn_count` forward.
|
|
25
25
|
5. `UserMemoryPostprocessor` runs after the assistant response.
|
|
@@ -29,13 +29,18 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
29
29
|
|
|
30
30
|
## Storage Model
|
|
31
31
|
|
|
32
|
-
Memory is stored in Unique content as Markdown
|
|
32
|
+
Memory is stored in Unique content as Markdown, under each user's own
|
|
33
|
+
root-level home folder (UN-24823):
|
|
33
34
|
|
|
34
35
|
```text
|
|
35
|
-
|
|
36
|
+
/home-<user_id>/<root_folder>/memory.md
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
By default, `root_folder` is `user-memory`.
|
|
39
|
+
By default, `root_folder` is `user-memory`. There is no shared root folder —
|
|
40
|
+
each user's home is created by node-ingestion (or, if missing, by this
|
|
41
|
+
package) with an exclusive owner ACL, so memory is never company-writable
|
|
42
|
+
(UN-24764). Users whose memory has not yet been migrated to their home
|
|
43
|
+
folder are still read from the legacy location, `/<root_folder>/<user_id>/memory.md`.
|
|
39
44
|
|
|
40
45
|
## Profile Format
|
|
41
46
|
|
|
@@ -92,7 +97,7 @@ config = UserMemoryConfig(
|
|
|
92
97
|
| `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
|
|
93
98
|
| `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
|
|
94
99
|
| `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
|
|
95
|
-
| `root_folder` | `user-memory` |
|
|
100
|
+
| `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>`. |
|
|
96
101
|
|
|
97
102
|
## Integration
|
|
98
103
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_user_memory"
|
|
3
|
-
version = "2026.36.0.
|
|
3
|
+
version = "2026.36.0.dev4"
|
|
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.36.0.
|
|
15
|
-
"unique-toolkit>=2026.36.0.
|
|
14
|
+
"unique-sdk>=2026.36.0.dev5,<2026.36.0rc0",
|
|
15
|
+
"unique-toolkit>=2026.36.0.dev12,<2026.36.0rc0",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[dependency-groups]
|
{unique_user_memory-2026.36.0.dev2 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/config.py
RENAMED
|
@@ -16,34 +16,28 @@ class UserMemoryConfig(BaseModel):
|
|
|
16
16
|
use_orchestrator_language_model: bool = Field(
|
|
17
17
|
default=True,
|
|
18
18
|
description=(
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"When false, the configured 'language_model' is used."
|
|
19
|
+
"Use the orchestrator's language model to update memory. "
|
|
20
|
+
"Turn off to use the language model configured below instead."
|
|
22
21
|
),
|
|
23
22
|
)
|
|
24
23
|
language_model: LMI = Field(
|
|
25
24
|
default=LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL),
|
|
26
|
-
description=
|
|
27
|
-
"The language model used for post-turn memory consolidation when "
|
|
28
|
-
"'Use Orchestrator Language Model' is false."
|
|
29
|
-
),
|
|
25
|
+
description="Language model used to update memory when the orchestrator's model is not used.",
|
|
30
26
|
)
|
|
31
27
|
max_tokens: Annotated[int, RJSFMetaTag.SpecialWidget.hidden()] = Field(
|
|
32
28
|
default=2000,
|
|
33
29
|
ge=500,
|
|
34
30
|
le=8000,
|
|
35
|
-
description="Maximum size of the memory profile in tokens.",
|
|
31
|
+
description="Maximum size of the memory profile, in tokens.",
|
|
36
32
|
)
|
|
37
33
|
consolidation_gate_enabled: bool = Field(
|
|
38
34
|
default=True,
|
|
39
|
-
description=
|
|
40
|
-
"When true, a cheap single-word LLM 'gate' decides whether the turn "
|
|
41
|
-
"warrants a full memory rewrite before the expensive consolidation "
|
|
42
|
-
"runs."
|
|
43
|
-
),
|
|
35
|
+
description="Skip memory updates for turns that add no new information, to save cost.",
|
|
44
36
|
)
|
|
45
37
|
root_folder: Annotated[str, RJSFMetaTag.SpecialWidget.hidden()] = Field(
|
|
46
38
|
default="user-memory",
|
|
47
39
|
min_length=1,
|
|
48
|
-
|
|
40
|
+
# Name used under the user's own home folder (UN-24823); also read
|
|
41
|
+
# as a legacy fallback for users not yet migrated (UN-24896).
|
|
42
|
+
description="Folder used to store the user's memory profile.",
|
|
49
43
|
)
|
|
@@ -26,6 +26,7 @@ from unique_user_memory.config import UserMemoryConfig
|
|
|
26
26
|
from unique_user_memory.user_memory import (
|
|
27
27
|
UserMemoryState,
|
|
28
28
|
_gate_max_tokens,
|
|
29
|
+
_is_well_formed_profile,
|
|
29
30
|
_sanitize_for_xml_context,
|
|
30
31
|
condense_user_memory,
|
|
31
32
|
consolidate_user_memory,
|
|
@@ -41,6 +42,7 @@ from unique_user_memory.user_memory import (
|
|
|
41
42
|
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
42
43
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
43
44
|
from unique_user_memory.user_memory_prompts import (
|
|
45
|
+
condensation_system_prompt,
|
|
44
46
|
consolidation_system_prompt,
|
|
45
47
|
empty_profile,
|
|
46
48
|
memory_gate_system_prompt,
|
|
@@ -49,6 +51,23 @@ from unique_user_memory.user_memory_prompts import (
|
|
|
49
51
|
_TEST_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL)
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _complete_profile_body(
|
|
55
|
+
identity: str = "- Prefers concise answers",
|
|
56
|
+
*,
|
|
57
|
+
legacy_title: bool = False,
|
|
58
|
+
) -> str:
|
|
59
|
+
sections = (
|
|
60
|
+
f"## Identity\n{identity}",
|
|
61
|
+
"## Communication Preferences\n_(empty)_",
|
|
62
|
+
"## Work Context\n_(empty)_",
|
|
63
|
+
"## Skills & Expertise\n_(empty)_",
|
|
64
|
+
"## Follow-ups\n_(empty)_",
|
|
65
|
+
"## Recent Topics\n_(empty)_",
|
|
66
|
+
)
|
|
67
|
+
body = "\n\n".join(sections)
|
|
68
|
+
return f"# User Memory\n\n{body}" if legacy_title else body
|
|
69
|
+
|
|
70
|
+
|
|
52
71
|
def _chat_event() -> ChatEvent:
|
|
53
72
|
return ChatEvent(
|
|
54
73
|
id="event_1",
|
|
@@ -111,6 +130,59 @@ def test_recent_topics_is_last_section_and_deduplicated() -> None:
|
|
|
111
130
|
assert "do NOT also log that same information" in consolidation_prompt
|
|
112
131
|
|
|
113
132
|
|
|
133
|
+
@pytest.mark.ai
|
|
134
|
+
def test_generated_profile_templates_start_with_identity_without_document_title() -> (
|
|
135
|
+
None
|
|
136
|
+
):
|
|
137
|
+
"""Purpose: Verify new profile output omits the obsolete document-level title.
|
|
138
|
+
Why this matters: Persistent memory should expose only its meaningful H2 sections.
|
|
139
|
+
Setup summary: Render empty and LLM prompts, then assert the required start format.
|
|
140
|
+
"""
|
|
141
|
+
profile = empty_profile("user_1")
|
|
142
|
+
prompts = (
|
|
143
|
+
consolidation_system_prompt(2000),
|
|
144
|
+
condensation_system_prompt(
|
|
145
|
+
max_tokens=2000,
|
|
146
|
+
current_tokens=2200,
|
|
147
|
+
target_tokens=1800,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert profile_body(profile).startswith("## Identity")
|
|
152
|
+
assert "# User Memory" not in profile
|
|
153
|
+
assert all("starting with\n`## Identity`" in prompt for prompt in prompts)
|
|
154
|
+
assert all(
|
|
155
|
+
"Do NOT add a document-level `# User Memory` title" in prompt
|
|
156
|
+
for prompt in prompts
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.ai
|
|
161
|
+
@pytest.mark.parametrize("legacy_title", [False, True])
|
|
162
|
+
def test_well_formed_profile_accepts_current_and_legacy_complete_profiles(
|
|
163
|
+
legacy_title: bool,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Purpose: Verify validation supports titleless output and stored legacy profiles.
|
|
166
|
+
Why this matters: New rewrites must migrate format without breaking old memories.
|
|
167
|
+
Setup summary: Validate complete profiles with and without the former H1 title.
|
|
168
|
+
"""
|
|
169
|
+
assert _is_well_formed_profile(_complete_profile_body(legacy_title=legacy_title))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.ai
|
|
173
|
+
def test_well_formed_profile_rejects_missing_required_section() -> None:
|
|
174
|
+
"""Purpose: Verify rewritten profiles retain every required H2 section.
|
|
175
|
+
Why this matters: A rewrite must not silently erase a category of user memory.
|
|
176
|
+
Setup summary: Remove Work Context from a complete profile and validate rejection.
|
|
177
|
+
"""
|
|
178
|
+
profile = _complete_profile_body().replace(
|
|
179
|
+
"## Work Context\n_(empty)_\n\n",
|
|
180
|
+
"",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert not _is_well_formed_profile(profile)
|
|
184
|
+
|
|
185
|
+
|
|
114
186
|
def test_profile_body_strips_frontmatter() -> None:
|
|
115
187
|
# The orchestrator renders this into the system prompt, where the
|
|
116
188
|
# bookkeeping fields are noise for the model.
|
|
@@ -190,7 +262,7 @@ async def test_fit_user_memory_condenses_before_hard_cut(
|
|
|
190
262
|
f"- fact number {index} that is fairly wordy about the user"
|
|
191
263
|
for index in range(400)
|
|
192
264
|
)
|
|
193
|
-
condensed = "
|
|
265
|
+
condensed = _complete_profile_body("- concise summary of the user")
|
|
194
266
|
condense = AsyncMock(return_value=condensed)
|
|
195
267
|
monkeypatch.setattr(
|
|
196
268
|
"unique_user_memory.user_memory.condense_user_memory",
|
|
@@ -264,10 +336,11 @@ async def test_condense_user_memory_accepts_frontmatter_output(
|
|
|
264
336
|
monkeypatch: pytest.MonkeyPatch,
|
|
265
337
|
) -> None:
|
|
266
338
|
"""Accept legacy LLM output while returning only the condensed profile body."""
|
|
267
|
-
condensed = "
|
|
339
|
+
condensed = _complete_profile_body("- concise summary")
|
|
268
340
|
response = MagicMock()
|
|
269
341
|
response.choices[0].message.content = (
|
|
270
|
-
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
342
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
343
|
+
+ _complete_profile_body("- concise summary", legacy_title=True)
|
|
271
344
|
)
|
|
272
345
|
llm_service = MagicMock()
|
|
273
346
|
llm_service.complete_async = AsyncMock(return_value=response)
|
|
@@ -296,8 +369,8 @@ async def test_user_memory_llm_paths_forward_event_attribution(
|
|
|
296
369
|
Why this matters: Every user-memory ModelUsage row must link to its chat.
|
|
297
370
|
Setup summary: Exercise all shared service paths and inspect toolkit calls.
|
|
298
371
|
"""
|
|
299
|
-
rewritten =
|
|
300
|
-
condensed = "
|
|
372
|
+
rewritten = _complete_profile_body()
|
|
373
|
+
condensed = _complete_profile_body("- Concise")
|
|
301
374
|
complete_async = AsyncMock(
|
|
302
375
|
side_effect=[
|
|
303
376
|
_completion_response("UPDATE"),
|
|
@@ -462,7 +535,7 @@ async def test_consolidate_user_memory_runs_full_rewrite_when_gate_update(
|
|
|
462
535
|
monkeypatch: pytest.MonkeyPatch,
|
|
463
536
|
) -> None:
|
|
464
537
|
current = empty_profile("user_1")
|
|
465
|
-
rewritten =
|
|
538
|
+
rewritten = _complete_profile_body()
|
|
466
539
|
response = MagicMock()
|
|
467
540
|
response.choices[0].message.content = rewritten
|
|
468
541
|
llm_service = MagicMock()
|
|
@@ -498,7 +571,7 @@ async def test_consolidate_user_memory_runs_full_rewrite_when_gate_update(
|
|
|
498
571
|
async def test_consolidate_user_memory_adds_frontmatter_to_llm_body(
|
|
499
572
|
monkeypatch: pytest.MonkeyPatch,
|
|
500
573
|
) -> None:
|
|
501
|
-
rewritten =
|
|
574
|
+
rewritten = _complete_profile_body()
|
|
502
575
|
response = MagicMock()
|
|
503
576
|
response.choices[0].message.content = rewritten
|
|
504
577
|
llm_service = MagicMock()
|
|
@@ -534,10 +607,11 @@ async def test_consolidate_user_memory_replaces_llm_frontmatter(
|
|
|
534
607
|
monkeypatch: pytest.MonkeyPatch,
|
|
535
608
|
) -> None:
|
|
536
609
|
"""Strip untrusted legacy metadata before assembling the updated profile."""
|
|
537
|
-
rewritten =
|
|
610
|
+
rewritten = _complete_profile_body()
|
|
538
611
|
response = MagicMock()
|
|
539
612
|
response.choices[0].message.content = (
|
|
540
|
-
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
613
|
+
"---\nuser_id: stale-user\nturn_count: 99\n---\n\n"
|
|
614
|
+
+ _complete_profile_body(legacy_title=True)
|
|
541
615
|
)
|
|
542
616
|
llm_service = MagicMock()
|
|
543
617
|
llm_service.complete_async = AsyncMock(return_value=response)
|
|
@@ -572,7 +646,7 @@ async def test_consolidate_user_memory_skips_gate_when_disabled(
|
|
|
572
646
|
monkeypatch: pytest.MonkeyPatch,
|
|
573
647
|
) -> None:
|
|
574
648
|
current = empty_profile("user_1")
|
|
575
|
-
rewritten =
|
|
649
|
+
rewritten = _complete_profile_body()
|
|
576
650
|
response = MagicMock()
|
|
577
651
|
response.choices[0].message.content = rewritten
|
|
578
652
|
llm_service = MagicMock()
|
|
@@ -729,7 +803,7 @@ async def test_should_consolidate_memory_falls_back_to_true_on_error(
|
|
|
729
803
|
async def test_consolidate_user_memory_invokes_update_callbacks_on_rewrite(
|
|
730
804
|
monkeypatch: pytest.MonkeyPatch,
|
|
731
805
|
) -> None:
|
|
732
|
-
rewritten =
|
|
806
|
+
rewritten = _complete_profile_body()
|
|
733
807
|
response = MagicMock()
|
|
734
808
|
response.choices[0].message.content = rewritten
|
|
735
809
|
llm_service = MagicMock()
|
|
@@ -1093,19 +1167,34 @@ async def test_download_user_memory_downloads_existing_file_to_memory(
|
|
|
1093
1167
|
)
|
|
1094
1168
|
|
|
1095
1169
|
|
|
1170
|
+
class _FakeSdkError(Exception):
|
|
1171
|
+
"""Stands in for unique_sdk's HTTP error hierarchy in folder-lookup tests.
|
|
1172
|
+
|
|
1173
|
+
Only the ``http_status`` attribute that ``_is_folder_not_found`` inspects
|
|
1174
|
+
matters here; using the real ``unique_sdk`` error classes would tie these
|
|
1175
|
+
tests to their exact constructor signatures for no benefit.
|
|
1176
|
+
"""
|
|
1177
|
+
|
|
1178
|
+
def __init__(self, *, http_status: int) -> None:
|
|
1179
|
+
super().__init__(f"http_status={http_status}")
|
|
1180
|
+
self.http_status = http_status
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _not_found_error() -> Exception:
|
|
1184
|
+
return _FakeSdkError(http_status=404)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _server_error() -> Exception:
|
|
1188
|
+
return _FakeSdkError(http_status=500)
|
|
1189
|
+
|
|
1190
|
+
|
|
1096
1191
|
@pytest.mark.asyncio
|
|
1097
|
-
async def
|
|
1192
|
+
async def test_ensure_user_memory_folder_returns_existing_home_folder(
|
|
1098
1193
|
monkeypatch: pytest.MonkeyPatch,
|
|
1099
1194
|
) -> None:
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
{"id": "scope_root"},
|
|
1103
|
-
{"id": "scope_user"},
|
|
1104
|
-
]
|
|
1105
|
-
)
|
|
1195
|
+
"""A fresh user with an already-provisioned home resolves without creating."""
|
|
1196
|
+
get_info = AsyncMock(return_value={"id": "scope_home"})
|
|
1106
1197
|
create_paths = AsyncMock()
|
|
1107
|
-
add_access = AsyncMock()
|
|
1108
|
-
get_groups = AsyncMock()
|
|
1109
1198
|
monkeypatch.setattr(
|
|
1110
1199
|
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
1111
1200
|
get_info,
|
|
@@ -1114,13 +1203,42 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
|
|
|
1114
1203
|
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1115
1204
|
create_paths,
|
|
1116
1205
|
)
|
|
1206
|
+
|
|
1207
|
+
result = await ensure_user_memory_folder(
|
|
1208
|
+
user_id="user_1",
|
|
1209
|
+
company_id="company_1",
|
|
1210
|
+
root_folder="user-memory",
|
|
1211
|
+
logger=MagicMock(),
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
assert result == "scope_home"
|
|
1215
|
+
get_info.assert_awaited_once_with(
|
|
1216
|
+
user_id="user_1",
|
|
1217
|
+
company_id="company_1",
|
|
1218
|
+
folderPath="/home-user_1/user-memory",
|
|
1219
|
+
)
|
|
1220
|
+
create_paths.assert_not_awaited()
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
@pytest.mark.asyncio
|
|
1224
|
+
async def test_ensure_user_memory_folder_falls_back_to_legacy_leaf_when_not_migrated(
|
|
1225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1226
|
+
) -> None:
|
|
1227
|
+
"""A user whose memory still lives at the legacy shared-root leaf stays readable."""
|
|
1228
|
+
get_info = AsyncMock(
|
|
1229
|
+
side_effect=[
|
|
1230
|
+
_not_found_error(),
|
|
1231
|
+
{"id": "scope_legacy"},
|
|
1232
|
+
]
|
|
1233
|
+
)
|
|
1234
|
+
create_paths = AsyncMock()
|
|
1117
1235
|
monkeypatch.setattr(
|
|
1118
|
-
"unique_user_memory.user_memory.unique_sdk.Folder.
|
|
1119
|
-
|
|
1236
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
1237
|
+
get_info,
|
|
1120
1238
|
)
|
|
1121
1239
|
monkeypatch.setattr(
|
|
1122
|
-
"unique_user_memory.user_memory.unique_sdk.
|
|
1123
|
-
|
|
1240
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1241
|
+
create_paths,
|
|
1124
1242
|
)
|
|
1125
1243
|
|
|
1126
1244
|
result = await ensure_user_memory_folder(
|
|
@@ -1130,11 +1248,11 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
|
|
|
1130
1248
|
logger=MagicMock(),
|
|
1131
1249
|
)
|
|
1132
1250
|
|
|
1133
|
-
assert result == "
|
|
1251
|
+
assert result == "scope_legacy"
|
|
1134
1252
|
get_info.assert_any_await(
|
|
1135
1253
|
user_id="user_1",
|
|
1136
1254
|
company_id="company_1",
|
|
1137
|
-
folderPath="/user-memory",
|
|
1255
|
+
folderPath="/home-user_1/user-memory",
|
|
1138
1256
|
)
|
|
1139
1257
|
get_info.assert_any_await(
|
|
1140
1258
|
user_id="user_1",
|
|
@@ -1142,22 +1260,28 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
|
|
|
1142
1260
|
folderPath="/user-memory/user_1",
|
|
1143
1261
|
)
|
|
1144
1262
|
create_paths.assert_not_awaited()
|
|
1145
|
-
get_groups.assert_not_awaited()
|
|
1146
|
-
add_access.assert_not_awaited()
|
|
1147
1263
|
|
|
1148
1264
|
|
|
1149
1265
|
@pytest.mark.asyncio
|
|
1150
|
-
async def
|
|
1266
|
+
async def test_ensure_user_memory_folder_creates_home_when_neither_location_exists(
|
|
1151
1267
|
monkeypatch: pytest.MonkeyPatch,
|
|
1152
1268
|
) -> None:
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1269
|
+
"""A brand-new user with no folder yet gets a home folder provisioned.
|
|
1270
|
+
|
|
1271
|
+
create_paths creates every missing segment of a nested path and returns
|
|
1272
|
+
them parent-first (regression guard for the bug where the parent
|
|
1273
|
+
`/home-<userId>` folder's id was returned instead of the leaf
|
|
1274
|
+
`/home-<userId>/user-memory` folder's id).
|
|
1275
|
+
"""
|
|
1276
|
+
get_info = AsyncMock(side_effect=[_not_found_error(), _not_found_error()])
|
|
1277
|
+
create_paths = AsyncMock(
|
|
1278
|
+
return_value={
|
|
1279
|
+
"createdFolders": [
|
|
1280
|
+
{"id": "scope_home_parent"},
|
|
1281
|
+
{"id": "scope_new_home_leaf"},
|
|
1282
|
+
]
|
|
1283
|
+
}
|
|
1158
1284
|
)
|
|
1159
|
-
create_paths = AsyncMock(return_value={"createdFolders": [{"id": "scope_user"}]})
|
|
1160
|
-
add_access = AsyncMock()
|
|
1161
1285
|
monkeypatch.setattr(
|
|
1162
1286
|
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
1163
1287
|
get_info,
|
|
@@ -1166,10 +1290,6 @@ async def test_ensure_user_memory_folder_creates_private_user_folder_under_root(
|
|
|
1166
1290
|
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1167
1291
|
create_paths,
|
|
1168
1292
|
)
|
|
1169
|
-
monkeypatch.setattr(
|
|
1170
|
-
"unique_user_memory.user_memory.unique_sdk.Folder.add_access_async",
|
|
1171
|
-
add_access,
|
|
1172
|
-
)
|
|
1173
1293
|
|
|
1174
1294
|
result = await ensure_user_memory_folder(
|
|
1175
1295
|
user_id="user_1",
|
|
@@ -1178,39 +1298,53 @@ async def test_ensure_user_memory_folder_creates_private_user_folder_under_root(
|
|
|
1178
1298
|
logger=MagicMock(),
|
|
1179
1299
|
)
|
|
1180
1300
|
|
|
1181
|
-
assert result == "
|
|
1301
|
+
assert result == "scope_new_home_leaf"
|
|
1182
1302
|
create_paths.assert_awaited_once_with(
|
|
1183
1303
|
user_id="user_1",
|
|
1184
1304
|
company_id="company_1",
|
|
1185
|
-
|
|
1186
|
-
relativePaths=["user_1"],
|
|
1305
|
+
paths=["/home-user_1/user-memory"],
|
|
1187
1306
|
inheritAccess=False,
|
|
1188
1307
|
)
|
|
1189
|
-
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
@pytest.mark.asyncio
|
|
1311
|
+
async def test_ensure_user_memory_folder_returns_none_when_home_creation_fails(
|
|
1312
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1313
|
+
) -> None:
|
|
1314
|
+
get_info = AsyncMock(side_effect=[_not_found_error(), _not_found_error()])
|
|
1315
|
+
create_paths = AsyncMock(side_effect=RuntimeError("create failed"))
|
|
1316
|
+
monkeypatch.setattr(
|
|
1317
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
1318
|
+
get_info,
|
|
1319
|
+
)
|
|
1320
|
+
monkeypatch.setattr(
|
|
1321
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1322
|
+
create_paths,
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
result = await ensure_user_memory_folder(
|
|
1190
1326
|
user_id="user_1",
|
|
1191
1327
|
company_id="company_1",
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
{"entityId": "user_1", "type": "READ", "entityType": "USER"},
|
|
1195
|
-
{"entityId": "user_1", "type": "WRITE", "entityType": "USER"},
|
|
1196
|
-
],
|
|
1197
|
-
applyToSubScopes=True,
|
|
1328
|
+
root_folder="user-memory",
|
|
1329
|
+
logger=MagicMock(),
|
|
1198
1330
|
)
|
|
1199
1331
|
|
|
1332
|
+
assert result is None
|
|
1333
|
+
|
|
1200
1334
|
|
|
1201
1335
|
@pytest.mark.asyncio
|
|
1202
|
-
async def
|
|
1336
|
+
async def test_ensure_user_memory_folder_does_not_provision_on_unverified_lookup_failure(
|
|
1203
1337
|
monkeypatch: pytest.MonkeyPatch,
|
|
1204
1338
|
) -> None:
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1339
|
+
"""A non-404 lookup error must never be treated as 'absent, safe to create'.
|
|
1340
|
+
|
|
1341
|
+
Regression guard for the UN-24896 defect shape: silently treating any
|
|
1342
|
+
lookup failure as "not found" and provisioning a new home would risk
|
|
1343
|
+
creating an empty home that shadows a real, still-readable memory.md
|
|
1344
|
+
once the transient error clears.
|
|
1345
|
+
"""
|
|
1346
|
+
get_info = AsyncMock(side_effect=[_server_error(), _not_found_error()])
|
|
1347
|
+
create_paths = AsyncMock()
|
|
1214
1348
|
logger = MagicMock()
|
|
1215
1349
|
monkeypatch.setattr(
|
|
1216
1350
|
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
@@ -1220,10 +1354,6 @@ async def test_ensure_user_memory_folder_returns_none_when_access_grant_fails_af
|
|
|
1220
1354
|
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1221
1355
|
create_paths,
|
|
1222
1356
|
)
|
|
1223
|
-
monkeypatch.setattr(
|
|
1224
|
-
"unique_user_memory.user_memory.unique_sdk.Folder.add_access_async",
|
|
1225
|
-
add_access,
|
|
1226
|
-
)
|
|
1227
1357
|
|
|
1228
1358
|
result = await ensure_user_memory_folder(
|
|
1229
1359
|
user_id="user_1",
|
|
@@ -1233,32 +1363,43 @@ async def test_ensure_user_memory_folder_returns_none_when_access_grant_fails_af
|
|
|
1233
1363
|
)
|
|
1234
1364
|
|
|
1235
1365
|
assert result is None
|
|
1236
|
-
create_paths.
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1366
|
+
create_paths.assert_not_awaited()
|
|
1367
|
+
logger.warning.assert_any_call(
|
|
1368
|
+
"[user-memory] no memory folder resolved and at least one lookup "
|
|
1369
|
+
"failed - running without memory instead of provisioning"
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
@pytest.mark.asyncio
|
|
1374
|
+
async def test_ensure_user_memory_folder_never_resolves_shared_root_path(
|
|
1375
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1376
|
+
) -> None:
|
|
1377
|
+
"""Regression guard: the retired shared `/user-memory` root (UN-24764/UN-24823)
|
|
1378
|
+
must never be looked up or (re-)created by this function."""
|
|
1379
|
+
get_info = AsyncMock(side_effect=[_not_found_error(), _not_found_error()])
|
|
1380
|
+
create_paths = AsyncMock(
|
|
1381
|
+
return_value={"createdFolders": [{"id": "scope_new_home"}]}
|
|
1382
|
+
)
|
|
1383
|
+
monkeypatch.setattr(
|
|
1384
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
|
|
1385
|
+
get_info,
|
|
1242
1386
|
)
|
|
1243
|
-
|
|
1387
|
+
monkeypatch.setattr(
|
|
1388
|
+
"unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
|
|
1389
|
+
create_paths,
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
await ensure_user_memory_folder(
|
|
1244
1393
|
user_id="user_1",
|
|
1245
1394
|
company_id="company_1",
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
{"entityId": "user_1", "type": "READ", "entityType": "USER"},
|
|
1249
|
-
{"entityId": "user_1", "type": "WRITE", "entityType": "USER"},
|
|
1250
|
-
],
|
|
1251
|
-
applyToSubScopes=True,
|
|
1252
|
-
)
|
|
1253
|
-
logger.warning.assert_called_with(
|
|
1254
|
-
"[user-memory] failed to grant read/write access on scope %s "
|
|
1255
|
-
"for user %s: [%s] %s",
|
|
1256
|
-
"scope_user",
|
|
1257
|
-
"user_1",
|
|
1258
|
-
"RuntimeError",
|
|
1259
|
-
grant_error,
|
|
1395
|
+
root_folder="user-memory",
|
|
1396
|
+
logger=MagicMock(),
|
|
1260
1397
|
)
|
|
1261
1398
|
|
|
1399
|
+
looked_up_paths = {call.kwargs["folderPath"] for call in get_info.await_args_list}
|
|
1400
|
+
assert "/user-memory" not in looked_up_paths
|
|
1401
|
+
assert create_paths.await_args.kwargs["paths"] == ["/home-user_1/user-memory"]
|
|
1402
|
+
|
|
1262
1403
|
|
|
1263
1404
|
@pytest.mark.asyncio
|
|
1264
1405
|
async def test_upload_user_memory_writes_hidden_skip_ingestion_file(
|
|
@@ -43,6 +43,11 @@ from unique_user_memory.user_memory_prompts import (
|
|
|
43
43
|
MEMORY_FILENAME = "memory.md"
|
|
44
44
|
MIME_TYPE = "text/markdown"
|
|
45
45
|
_LLM_OUTPUT_HEADROOM_TOKENS = 200
|
|
46
|
+
# Mirrors node-ingestion's scope.utils USER_HOME_SCOPE_PREFIX (UN-24823): the
|
|
47
|
+
# shared, company-writable `/user-memory` root was retired in favour of a
|
|
48
|
+
# per-user root-level home folder. See conduct/runner/user_memory.py in the
|
|
49
|
+
# monorepo for the reference implementation this mirrors (UN-24896).
|
|
50
|
+
USER_HOME_FOLDER_PREFIX = "home-"
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
async def noop_update_callback() -> None:
|
|
@@ -69,6 +74,8 @@ _TRUNCATION_MARKER = "\n\n<!-- truncated to fit memory budget -->"
|
|
|
69
74
|
_DEFAULT_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_GPT_4o)
|
|
70
75
|
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
|
71
76
|
_TURN_COUNT_RE = re.compile(r"^turn_count:\s*(\d+)\s*$", re.MULTILINE)
|
|
77
|
+
_LEGACY_PROFILE_TITLE_RE = re.compile(r"^# User Memory[ \t]*(?:\n+|$)")
|
|
78
|
+
_H2_HEADING_RE = re.compile(r"^## (.+?)[ \t]*$", re.MULTILINE)
|
|
72
79
|
|
|
73
80
|
|
|
74
81
|
def profile_body(content: str) -> str:
|
|
@@ -286,7 +293,7 @@ async def condense_user_memory(
|
|
|
286
293
|
)
|
|
287
294
|
return None
|
|
288
295
|
|
|
289
|
-
candidate = profile_body(_strip_code_fences(raw))
|
|
296
|
+
candidate = _without_legacy_profile_title(profile_body(_strip_code_fences(raw)))
|
|
290
297
|
if not _is_well_formed_profile(candidate):
|
|
291
298
|
logger.warning(
|
|
292
299
|
"[user-memory] condense output did not look like a profile (%d chars)",
|
|
@@ -414,132 +421,154 @@ async def load_user_memory(
|
|
|
414
421
|
)
|
|
415
422
|
|
|
416
423
|
|
|
417
|
-
|
|
424
|
+
def _home_memory_folder_path(*, user_id: str, root_folder: str) -> str:
|
|
425
|
+
"""Canonical memory location post-UN-24823: `/home-<userId>/<root_folder>`."""
|
|
426
|
+
return f"/{USER_HOME_FOLDER_PREFIX}{user_id}/{root_folder.strip('/')}"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _legacy_memory_folder_path(*, user_id: str, root_folder: str) -> str:
|
|
430
|
+
"""Pre-UN-24823 location (`/<root_folder>/<userId>`), read during rollout."""
|
|
431
|
+
return f"/{root_folder.strip('/')}/{user_id}"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _is_folder_not_found(exc: Exception) -> bool:
|
|
435
|
+
"""True only for a genuine 404 from the folder-info endpoint."""
|
|
436
|
+
status = getattr(exc, "http_status", None)
|
|
437
|
+
code = getattr(exc, "code", None)
|
|
438
|
+
return status == 404 or code == 404 or code == "404"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def _resolve_existing_memory_folder(
|
|
418
442
|
*,
|
|
419
443
|
user_id: str,
|
|
420
444
|
company_id: str,
|
|
421
445
|
root_folder: str,
|
|
422
446
|
logger: Logger,
|
|
423
|
-
) -> str | None:
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
447
|
+
) -> tuple[str | None, bool]:
|
|
448
|
+
"""Look up the user's memory folder: canonical home first, legacy second.
|
|
449
|
+
|
|
450
|
+
Mirrors conduct/runner/user_memory.py::_resolve_existing_memory_folder in
|
|
451
|
+
the monorepo (UN-24896/UN-24823). Returns ``(scope_id, lookup_failed)``:
|
|
452
|
+
a resolved folder always wins; when neither location resolves,
|
|
453
|
+
``lookup_failed`` distinguishes a verified absence (both lookups 404 -
|
|
454
|
+
safe to provision the home) from an unverified one (some other error -
|
|
455
|
+
the caller must not provision, or it risks creating an empty home that
|
|
456
|
+
orphans a real, still-legacy ``memory.md``).
|
|
457
|
+
"""
|
|
458
|
+
lookup_failed = False
|
|
459
|
+
for folder_path in (
|
|
460
|
+
_home_memory_folder_path(user_id=user_id, root_folder=root_folder),
|
|
461
|
+
_legacy_memory_folder_path(user_id=user_id, root_folder=root_folder),
|
|
462
|
+
):
|
|
463
|
+
try:
|
|
464
|
+
info = await unique_sdk.Folder.get_info_async(
|
|
465
|
+
user_id=user_id,
|
|
466
|
+
company_id=company_id,
|
|
467
|
+
folderPath=folder_path,
|
|
468
|
+
)
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
if _is_folder_not_found(exc):
|
|
471
|
+
continue
|
|
472
|
+
logger.warning(
|
|
473
|
+
"[user-memory] memory folder lookup failed for %s: [%s] %s",
|
|
474
|
+
folder_path,
|
|
475
|
+
type(exc).__name__,
|
|
476
|
+
exc,
|
|
477
|
+
)
|
|
478
|
+
lookup_failed = True
|
|
479
|
+
continue
|
|
480
|
+
# A missing id (e.g. a 200 with an empty body) falls through to the
|
|
481
|
+
# next location the same way a 404 does.
|
|
482
|
+
scope_id = (info or {}).get("id")
|
|
483
|
+
if scope_id:
|
|
484
|
+
logger.debug(
|
|
485
|
+
"[user-memory] resolved existing memory folder %s", folder_path
|
|
486
|
+
)
|
|
487
|
+
return scope_id, False
|
|
432
488
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
user_id=user_id,
|
|
438
|
-
company_id=company_id,
|
|
439
|
-
folderPath=user_folder_path,
|
|
489
|
+
if lookup_failed:
|
|
490
|
+
logger.warning(
|
|
491
|
+
"[user-memory] no memory folder resolved and at least one lookup "
|
|
492
|
+
"failed - running without memory instead of provisioning"
|
|
440
493
|
)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
logger.warning("[user-memory] user memory folder not found - creating new one")
|
|
494
|
+
return None, lookup_failed
|
|
495
|
+
|
|
444
496
|
|
|
497
|
+
async def _create_home_memory_folder(
|
|
498
|
+
*,
|
|
499
|
+
user_id: str,
|
|
500
|
+
company_id: str,
|
|
501
|
+
root_folder: str,
|
|
502
|
+
logger: Logger,
|
|
503
|
+
) -> str | None:
|
|
504
|
+
"""Provision `/home-<userId>/<root_folder>` with one idempotent call.
|
|
505
|
+
|
|
506
|
+
node-ingestion creates the caller's root-level home with an exclusive
|
|
507
|
+
owner ACL and repairs the ACL to owner-only on every pass (UN-24823).
|
|
508
|
+
There is no shared parent folder, so - unlike the retired shared-root
|
|
509
|
+
model - no separate access grant is needed here.
|
|
510
|
+
"""
|
|
511
|
+
folder_path = _home_memory_folder_path(user_id=user_id, root_folder=root_folder)
|
|
445
512
|
try:
|
|
446
513
|
created = await unique_sdk.Folder.create_paths_async(
|
|
447
514
|
user_id=user_id,
|
|
448
515
|
company_id=company_id,
|
|
449
|
-
|
|
450
|
-
relativePaths=[user_id],
|
|
516
|
+
paths=[folder_path],
|
|
451
517
|
inheritAccess=False,
|
|
452
518
|
)
|
|
453
519
|
except Exception as exc:
|
|
454
520
|
logger.warning(
|
|
455
|
-
"[user-memory] failed to
|
|
456
|
-
|
|
521
|
+
"[user-memory] failed to ensure memory folder %s: [%s] %s",
|
|
522
|
+
folder_path,
|
|
457
523
|
type(exc).__name__,
|
|
458
524
|
exc,
|
|
459
525
|
)
|
|
460
526
|
return None
|
|
461
527
|
|
|
462
528
|
created_folders = (created or {}).get("createdFolders", []) or []
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
len(created_folders),
|
|
468
|
-
user_folder_path,
|
|
469
|
-
)
|
|
470
|
-
scope_id = created_folders[0].get("id") if created_folders else None
|
|
529
|
+
# create_paths returns every newly-created segment, parent first (e.g. the
|
|
530
|
+
# home folder itself, then the memory subfolder if both were missing) -
|
|
531
|
+
# the leaf we actually want is always the last entry, never the first.
|
|
532
|
+
scope_id = created_folders[-1].get("id") if created_folders else None
|
|
471
533
|
if not scope_id:
|
|
472
534
|
logger.warning(
|
|
473
535
|
"[user-memory] create_paths returned no folder id for %s",
|
|
474
|
-
|
|
536
|
+
folder_path,
|
|
475
537
|
)
|
|
476
538
|
return None
|
|
477
|
-
|
|
478
|
-
try:
|
|
479
|
-
await unique_sdk.Folder.add_access_async(
|
|
480
|
-
user_id=user_id,
|
|
481
|
-
company_id=company_id,
|
|
482
|
-
scopeId=scope_id,
|
|
483
|
-
scopeAccesses=[
|
|
484
|
-
{
|
|
485
|
-
"entityId": user_id,
|
|
486
|
-
"type": "READ",
|
|
487
|
-
"entityType": "USER",
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
"entityId": user_id,
|
|
491
|
-
"type": "WRITE",
|
|
492
|
-
"entityType": "USER",
|
|
493
|
-
},
|
|
494
|
-
],
|
|
495
|
-
applyToSubScopes=True,
|
|
496
|
-
)
|
|
497
|
-
except Exception as exc:
|
|
498
|
-
logger.warning(
|
|
499
|
-
"[user-memory] failed to grant read/write access on scope %s "
|
|
500
|
-
"for user %s: [%s] %s",
|
|
501
|
-
scope_id,
|
|
502
|
-
user_id,
|
|
503
|
-
type(exc).__name__,
|
|
504
|
-
exc,
|
|
505
|
-
)
|
|
506
|
-
return None
|
|
507
|
-
|
|
508
539
|
return scope_id
|
|
509
540
|
|
|
510
541
|
|
|
511
|
-
async def
|
|
542
|
+
async def ensure_user_memory_folder(
|
|
512
543
|
*,
|
|
513
544
|
user_id: str,
|
|
514
545
|
company_id: str,
|
|
515
546
|
root_folder: str,
|
|
516
547
|
logger: Logger,
|
|
517
548
|
) -> str | None:
|
|
518
|
-
|
|
519
|
-
try:
|
|
520
|
-
root_info = await unique_sdk.Folder.get_info_async(
|
|
521
|
-
user_id=user_id,
|
|
522
|
-
company_id=company_id,
|
|
523
|
-
folderPath=root_path,
|
|
524
|
-
)
|
|
525
|
-
except Exception as exc:
|
|
526
|
-
logger.warning(
|
|
527
|
-
"[user-memory] failed to resolve pre-provisioned root folder %s: [%s] %s",
|
|
528
|
-
root_path,
|
|
529
|
-
type(exc).__name__,
|
|
530
|
-
exc,
|
|
531
|
-
)
|
|
532
|
-
return None
|
|
549
|
+
"""Resolve the user's memory folder, creating the home if needed (UN-24823).
|
|
533
550
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
551
|
+
Read-before-provision, mirroring node-chat/conduct: an existing folder
|
|
552
|
+
(canonical home, falling back to the legacy shared-root leaf for
|
|
553
|
+
not-yet-migrated users) always wins, and the home is only provisioned
|
|
554
|
+
when both locations are genuinely absent.
|
|
555
|
+
"""
|
|
556
|
+
scope_id, lookup_failed = await _resolve_existing_memory_folder(
|
|
557
|
+
user_id=user_id,
|
|
558
|
+
company_id=company_id,
|
|
559
|
+
root_folder=root_folder,
|
|
560
|
+
logger=logger,
|
|
561
|
+
)
|
|
562
|
+
if scope_id is not None:
|
|
563
|
+
return scope_id
|
|
564
|
+
if lookup_failed:
|
|
540
565
|
return None
|
|
541
|
-
|
|
542
|
-
|
|
566
|
+
return await _create_home_memory_folder(
|
|
567
|
+
user_id=user_id,
|
|
568
|
+
company_id=company_id,
|
|
569
|
+
root_folder=root_folder,
|
|
570
|
+
logger=logger,
|
|
571
|
+
)
|
|
543
572
|
|
|
544
573
|
|
|
545
574
|
async def download_user_memory(
|
|
@@ -906,7 +935,9 @@ async def _rewrite_user_memory(
|
|
|
906
935
|
logger.info("[user-memory] consolidation NOOP - keeping existing memory")
|
|
907
936
|
return safe_current
|
|
908
937
|
|
|
909
|
-
candidate_body =
|
|
938
|
+
candidate_body = _without_legacy_profile_title(
|
|
939
|
+
profile_body(_strip_code_fences(raw))
|
|
940
|
+
)
|
|
910
941
|
if not _is_well_formed_profile(candidate_body):
|
|
911
942
|
logger.warning(
|
|
912
943
|
"[user-memory] LLM output did not look like a profile (%d chars)",
|
|
@@ -947,7 +978,14 @@ def _sanitize_for_xml_context(text: str) -> str:
|
|
|
947
978
|
def _is_well_formed_profile(content: str) -> bool:
|
|
948
979
|
if not content or len(content.strip()) < 20:
|
|
949
980
|
return False
|
|
950
|
-
|
|
981
|
+
body = _without_legacy_profile_title(content.strip())
|
|
982
|
+
headings = tuple(_H2_HEADING_RE.findall(body))
|
|
983
|
+
return body.startswith("## Identity") and headings == SECTION_HEADINGS
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _without_legacy_profile_title(content: str) -> str:
|
|
987
|
+
"""Remove the former document title while preserving the H2 profile body."""
|
|
988
|
+
return _LEGACY_PROFILE_TITLE_RE.sub("", content.strip(), count=1).lstrip()
|
|
951
989
|
|
|
952
990
|
|
|
953
991
|
def _strip_code_fences(text: str) -> str:
|
|
@@ -19,8 +19,6 @@ last_updated: {{ timestamp }}
|
|
|
19
19
|
turn_count: 0
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
# User Memory
|
|
23
|
-
|
|
24
22
|
{% for heading in section_headings -%}
|
|
25
23
|
## {{ heading }}
|
|
26
24
|
_(empty)_
|
|
@@ -60,8 +58,9 @@ You receive two XML blocks:
|
|
|
60
58
|
# Output
|
|
61
59
|
|
|
62
60
|
Return the complete, rewritten Markdown profile body, starting with
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
`## Identity`. Do NOT add a document-level `# User Memory` title. Do NOT
|
|
62
|
+
emit a diff. Do NOT wrap the output in ``` fences. Do NOT add commentary
|
|
63
|
+
before or after the body.
|
|
65
64
|
|
|
66
65
|
The body MUST contain exactly these section headings, in this order, even
|
|
67
66
|
when a section is empty (use the literal string `_(empty)_` as a placeholder):
|
|
@@ -256,8 +255,8 @@ fact about the user. This is lossy compression, not deletion of meaning.
|
|
|
256
255
|
# Output
|
|
257
256
|
|
|
258
257
|
Return the complete rewritten profile body, starting with
|
|
259
|
-
|
|
260
|
-
and do NOT wrap the output in ``` fences.
|
|
258
|
+
`## Identity`. Do NOT add a document-level `# User Memory` title. Do NOT
|
|
259
|
+
emit a diff or commentary, and do NOT wrap the output in ``` fences.
|
|
261
260
|
"""
|
|
262
261
|
|
|
263
262
|
|
|
File without changes
|
|
File without changes
|