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.
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-user-memory
3
- Version: 2026.36.0.dev2
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.dev1,<2026.36.0rc0
11
- Requires-Dist: unique-toolkit>=2026.36.0.dev6,<2026.36.0rc0
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 pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
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
- /<root_folder>/<user_id>/memory.md
50
+ /home-<user_id>/<root_folder>/memory.md
50
51
  ```
51
52
 
52
- By default, `root_folder` is `user-memory`. The root folder must already exist. The package creates the per-user child folder when needed.
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` | Root KB folder that contains per-user memory folders. |
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 pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
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
- /<root_folder>/<user_id>/memory.md
36
+ /home-<user_id>/<root_folder>/memory.md
36
37
  ```
37
38
 
38
- By default, `root_folder` is `user-memory`. The root folder must already exist. The package creates the per-user child folder when needed.
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` | Root KB folder that contains per-user memory folders. |
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.dev2"
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.dev1,<2026.36.0rc0",
15
- "unique-toolkit>=2026.36.0.dev6,<2026.36.0rc0",
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]
@@ -16,34 +16,28 @@ class UserMemoryConfig(BaseModel):
16
16
  use_orchestrator_language_model: bool = Field(
17
17
  default=True,
18
18
  description=(
19
- "When true, post-turn memory consolidation uses the orchestrator's "
20
- "language model and the configured 'language_model' is ignored. "
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
- description="Root KB folder used to store per-user memory profiles.",
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 = "# User Memory\n\n## Identity\n- concise summary of the user"
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 = "# User Memory\n\n## Identity\n- concise summary"
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" + condensed
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
300
- condensed = "# User Memory\n\n## Identity\n- Concise"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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" + rewritten
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 = "# User Memory\n\n## Identity\n- Prefers concise answers"
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 test_ensure_user_memory_folder_returns_existing_user_folder(
1192
+ async def test_ensure_user_memory_folder_returns_existing_home_folder(
1098
1193
  monkeypatch: pytest.MonkeyPatch,
1099
1194
  ) -> None:
1100
- get_info = AsyncMock(
1101
- side_effect=[
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.add_access_async",
1119
- add_access,
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.Group.get_groups_async",
1123
- get_groups,
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 == "scope_user"
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 test_ensure_user_memory_folder_creates_private_user_folder_under_root(
1266
+ async def test_ensure_user_memory_folder_creates_home_when_neither_location_exists(
1151
1267
  monkeypatch: pytest.MonkeyPatch,
1152
1268
  ) -> None:
1153
- get_info = AsyncMock(
1154
- side_effect=[
1155
- {"id": "scope_root"},
1156
- RuntimeError("missing user folder"),
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 == "scope_user"
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
- parentScopeId="scope_root",
1186
- relativePaths=["user_1"],
1305
+ paths=["/home-user_1/user-memory"],
1187
1306
  inheritAccess=False,
1188
1307
  )
1189
- add_access.assert_awaited_once_with(
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
- scopeId="scope_user",
1193
- scopeAccesses=[
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 test_ensure_user_memory_folder_returns_none_when_access_grant_fails_after_create(
1336
+ async def test_ensure_user_memory_folder_does_not_provision_on_unverified_lookup_failure(
1203
1337
  monkeypatch: pytest.MonkeyPatch,
1204
1338
  ) -> None:
1205
- get_info = AsyncMock(
1206
- side_effect=[
1207
- {"id": "scope_root"},
1208
- RuntimeError("missing user folder"),
1209
- ]
1210
- )
1211
- create_paths = AsyncMock(return_value={"createdFolders": [{"id": "scope_user"}]})
1212
- grant_error = RuntimeError("grant failed")
1213
- add_access = AsyncMock(side_effect=grant_error)
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.assert_awaited_once_with(
1237
- user_id="user_1",
1238
- company_id="company_1",
1239
- parentScopeId="scope_root",
1240
- relativePaths=["user_1"],
1241
- inheritAccess=False,
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
- add_access.assert_awaited_once_with(
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
- scopeId="scope_user",
1247
- scopeAccesses=[
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
- async def ensure_user_memory_folder(
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
- root_scope_id = await _resolve_root_folder(
425
- user_id=user_id,
426
- company_id=company_id,
427
- root_folder=root_folder,
428
- logger=logger,
429
- )
430
- if root_scope_id is None:
431
- return None
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
- user_folder_path = f"/{root_folder.strip('/')}/{user_id}"
434
- scope_id: str | None = None
435
- try:
436
- info = await unique_sdk.Folder.get_info_async(
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
- return info.get("id")
442
- except Exception:
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
- parentScopeId=root_scope_id,
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 create user folder %s: [%s] %s",
456
- user_folder_path,
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
- if len(created_folders) > 1:
464
- logger.warning(
465
- "[user-memory] create_paths returned %d folders for %s, "
466
- "expected exactly 1; using the first one",
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
- user_folder_path,
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 _resolve_root_folder(
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
- root_path = f"/{root_folder.strip('/')}"
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
- root_scope_id = root_info.get("id")
535
- if not root_scope_id:
536
- logger.warning(
537
- "[user-memory] root folder lookup returned no id for %s",
538
- root_path,
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
- return root_scope_id
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 = profile_body(_strip_code_fences(raw))
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
- return content.startswith("# User Memory") and "## Identity" in content
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
- `# User Memory`. Do NOT emit a diff. Do NOT wrap the output in
64
- ``` fences. Do NOT add commentary before or after the body.
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
- `# User Memory`. Do NOT emit a diff, or commentary,
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