unique-user-memory 2026.36.0.dev3__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.dev3
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.dev3"
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
  )
@@ -1167,19 +1167,34 @@ async def test_download_user_memory_downloads_existing_file_to_memory(
1167
1167
  )
1168
1168
 
1169
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
+
1170
1191
  @pytest.mark.asyncio
1171
- async def test_ensure_user_memory_folder_returns_existing_user_folder(
1192
+ async def test_ensure_user_memory_folder_returns_existing_home_folder(
1172
1193
  monkeypatch: pytest.MonkeyPatch,
1173
1194
  ) -> None:
1174
- get_info = AsyncMock(
1175
- side_effect=[
1176
- {"id": "scope_root"},
1177
- {"id": "scope_user"},
1178
- ]
1179
- )
1195
+ """A fresh user with an already-provisioned home resolves without creating."""
1196
+ get_info = AsyncMock(return_value={"id": "scope_home"})
1180
1197
  create_paths = AsyncMock()
1181
- add_access = AsyncMock()
1182
- get_groups = AsyncMock()
1183
1198
  monkeypatch.setattr(
1184
1199
  "unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
1185
1200
  get_info,
@@ -1188,13 +1203,42 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
1188
1203
  "unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
1189
1204
  create_paths,
1190
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()
1191
1235
  monkeypatch.setattr(
1192
- "unique_user_memory.user_memory.unique_sdk.Folder.add_access_async",
1193
- add_access,
1236
+ "unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
1237
+ get_info,
1194
1238
  )
1195
1239
  monkeypatch.setattr(
1196
- "unique_user_memory.user_memory.unique_sdk.Group.get_groups_async",
1197
- get_groups,
1240
+ "unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
1241
+ create_paths,
1198
1242
  )
1199
1243
 
1200
1244
  result = await ensure_user_memory_folder(
@@ -1204,11 +1248,11 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
1204
1248
  logger=MagicMock(),
1205
1249
  )
1206
1250
 
1207
- assert result == "scope_user"
1251
+ assert result == "scope_legacy"
1208
1252
  get_info.assert_any_await(
1209
1253
  user_id="user_1",
1210
1254
  company_id="company_1",
1211
- folderPath="/user-memory",
1255
+ folderPath="/home-user_1/user-memory",
1212
1256
  )
1213
1257
  get_info.assert_any_await(
1214
1258
  user_id="user_1",
@@ -1216,22 +1260,28 @@ async def test_ensure_user_memory_folder_returns_existing_user_folder(
1216
1260
  folderPath="/user-memory/user_1",
1217
1261
  )
1218
1262
  create_paths.assert_not_awaited()
1219
- get_groups.assert_not_awaited()
1220
- add_access.assert_not_awaited()
1221
1263
 
1222
1264
 
1223
1265
  @pytest.mark.asyncio
1224
- 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(
1225
1267
  monkeypatch: pytest.MonkeyPatch,
1226
1268
  ) -> None:
1227
- get_info = AsyncMock(
1228
- side_effect=[
1229
- {"id": "scope_root"},
1230
- RuntimeError("missing user folder"),
1231
- ]
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
+ }
1232
1284
  )
1233
- create_paths = AsyncMock(return_value={"createdFolders": [{"id": "scope_user"}]})
1234
- add_access = AsyncMock()
1235
1285
  monkeypatch.setattr(
1236
1286
  "unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
1237
1287
  get_info,
@@ -1240,10 +1290,6 @@ async def test_ensure_user_memory_folder_creates_private_user_folder_under_root(
1240
1290
  "unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
1241
1291
  create_paths,
1242
1292
  )
1243
- monkeypatch.setattr(
1244
- "unique_user_memory.user_memory.unique_sdk.Folder.add_access_async",
1245
- add_access,
1246
- )
1247
1293
 
1248
1294
  result = await ensure_user_memory_folder(
1249
1295
  user_id="user_1",
@@ -1252,39 +1298,53 @@ async def test_ensure_user_memory_folder_creates_private_user_folder_under_root(
1252
1298
  logger=MagicMock(),
1253
1299
  )
1254
1300
 
1255
- assert result == "scope_user"
1301
+ assert result == "scope_new_home_leaf"
1256
1302
  create_paths.assert_awaited_once_with(
1257
1303
  user_id="user_1",
1258
1304
  company_id="company_1",
1259
- parentScopeId="scope_root",
1260
- relativePaths=["user_1"],
1305
+ paths=["/home-user_1/user-memory"],
1261
1306
  inheritAccess=False,
1262
1307
  )
1263
- 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(
1264
1326
  user_id="user_1",
1265
1327
  company_id="company_1",
1266
- scopeId="scope_user",
1267
- scopeAccesses=[
1268
- {"entityId": "user_1", "type": "READ", "entityType": "USER"},
1269
- {"entityId": "user_1", "type": "WRITE", "entityType": "USER"},
1270
- ],
1271
- applyToSubScopes=True,
1328
+ root_folder="user-memory",
1329
+ logger=MagicMock(),
1272
1330
  )
1273
1331
 
1332
+ assert result is None
1333
+
1274
1334
 
1275
1335
  @pytest.mark.asyncio
1276
- 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(
1277
1337
  monkeypatch: pytest.MonkeyPatch,
1278
1338
  ) -> None:
1279
- get_info = AsyncMock(
1280
- side_effect=[
1281
- {"id": "scope_root"},
1282
- RuntimeError("missing user folder"),
1283
- ]
1284
- )
1285
- create_paths = AsyncMock(return_value={"createdFolders": [{"id": "scope_user"}]})
1286
- grant_error = RuntimeError("grant failed")
1287
- 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()
1288
1348
  logger = MagicMock()
1289
1349
  monkeypatch.setattr(
1290
1350
  "unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
@@ -1294,10 +1354,6 @@ async def test_ensure_user_memory_folder_returns_none_when_access_grant_fails_af
1294
1354
  "unique_user_memory.user_memory.unique_sdk.Folder.create_paths_async",
1295
1355
  create_paths,
1296
1356
  )
1297
- monkeypatch.setattr(
1298
- "unique_user_memory.user_memory.unique_sdk.Folder.add_access_async",
1299
- add_access,
1300
- )
1301
1357
 
1302
1358
  result = await ensure_user_memory_folder(
1303
1359
  user_id="user_1",
@@ -1307,32 +1363,43 @@ async def test_ensure_user_memory_folder_returns_none_when_access_grant_fails_af
1307
1363
  )
1308
1364
 
1309
1365
  assert result is None
1310
- create_paths.assert_awaited_once_with(
1311
- user_id="user_1",
1312
- company_id="company_1",
1313
- parentScopeId="scope_root",
1314
- relativePaths=["user_1"],
1315
- 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"}]}
1316
1382
  )
1317
- add_access.assert_awaited_once_with(
1383
+ monkeypatch.setattr(
1384
+ "unique_user_memory.user_memory.unique_sdk.Folder.get_info_async",
1385
+ get_info,
1386
+ )
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(
1318
1393
  user_id="user_1",
1319
1394
  company_id="company_1",
1320
- scopeId="scope_user",
1321
- scopeAccesses=[
1322
- {"entityId": "user_1", "type": "READ", "entityType": "USER"},
1323
- {"entityId": "user_1", "type": "WRITE", "entityType": "USER"},
1324
- ],
1325
- applyToSubScopes=True,
1326
- )
1327
- logger.warning.assert_called_with(
1328
- "[user-memory] failed to grant read/write access on scope %s "
1329
- "for user %s: [%s] %s",
1330
- "scope_user",
1331
- "user_1",
1332
- "RuntimeError",
1333
- grant_error,
1395
+ root_folder="user-memory",
1396
+ logger=MagicMock(),
1334
1397
  )
1335
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
+
1336
1403
 
1337
1404
  @pytest.mark.asyncio
1338
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:
@@ -416,132 +421,154 @@ async def load_user_memory(
416
421
  )
417
422
 
418
423
 
419
- 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(
420
442
  *,
421
443
  user_id: str,
422
444
  company_id: str,
423
445
  root_folder: str,
424
446
  logger: Logger,
425
- ) -> str | None:
426
- root_scope_id = await _resolve_root_folder(
427
- user_id=user_id,
428
- company_id=company_id,
429
- root_folder=root_folder,
430
- logger=logger,
431
- )
432
- if root_scope_id is None:
433
- 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
434
488
 
435
- user_folder_path = f"/{root_folder.strip('/')}/{user_id}"
436
- scope_id: str | None = None
437
- try:
438
- info = await unique_sdk.Folder.get_info_async(
439
- user_id=user_id,
440
- company_id=company_id,
441
- 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"
442
493
  )
443
- return info.get("id")
444
- except Exception:
445
- logger.warning("[user-memory] user memory folder not found - creating new one")
494
+ return None, lookup_failed
495
+
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.
446
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)
447
512
  try:
448
513
  created = await unique_sdk.Folder.create_paths_async(
449
514
  user_id=user_id,
450
515
  company_id=company_id,
451
- parentScopeId=root_scope_id,
452
- relativePaths=[user_id],
516
+ paths=[folder_path],
453
517
  inheritAccess=False,
454
518
  )
455
519
  except Exception as exc:
456
520
  logger.warning(
457
- "[user-memory] failed to create user folder %s: [%s] %s",
458
- user_folder_path,
521
+ "[user-memory] failed to ensure memory folder %s: [%s] %s",
522
+ folder_path,
459
523
  type(exc).__name__,
460
524
  exc,
461
525
  )
462
526
  return None
463
527
 
464
528
  created_folders = (created or {}).get("createdFolders", []) or []
465
- if len(created_folders) > 1:
466
- logger.warning(
467
- "[user-memory] create_paths returned %d folders for %s, "
468
- "expected exactly 1; using the first one",
469
- len(created_folders),
470
- user_folder_path,
471
- )
472
- 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
473
533
  if not scope_id:
474
534
  logger.warning(
475
535
  "[user-memory] create_paths returned no folder id for %s",
476
- user_folder_path,
477
- )
478
- return None
479
-
480
- try:
481
- await unique_sdk.Folder.add_access_async(
482
- user_id=user_id,
483
- company_id=company_id,
484
- scopeId=scope_id,
485
- scopeAccesses=[
486
- {
487
- "entityId": user_id,
488
- "type": "READ",
489
- "entityType": "USER",
490
- },
491
- {
492
- "entityId": user_id,
493
- "type": "WRITE",
494
- "entityType": "USER",
495
- },
496
- ],
497
- applyToSubScopes=True,
498
- )
499
- except Exception as exc:
500
- logger.warning(
501
- "[user-memory] failed to grant read/write access on scope %s "
502
- "for user %s: [%s] %s",
503
- scope_id,
504
- user_id,
505
- type(exc).__name__,
506
- exc,
536
+ folder_path,
507
537
  )
508
538
  return None
509
-
510
539
  return scope_id
511
540
 
512
541
 
513
- async def _resolve_root_folder(
542
+ async def ensure_user_memory_folder(
514
543
  *,
515
544
  user_id: str,
516
545
  company_id: str,
517
546
  root_folder: str,
518
547
  logger: Logger,
519
548
  ) -> str | None:
520
- root_path = f"/{root_folder.strip('/')}"
521
- try:
522
- root_info = await unique_sdk.Folder.get_info_async(
523
- user_id=user_id,
524
- company_id=company_id,
525
- folderPath=root_path,
526
- )
527
- except Exception as exc:
528
- logger.warning(
529
- "[user-memory] failed to resolve pre-provisioned root folder %s: [%s] %s",
530
- root_path,
531
- type(exc).__name__,
532
- exc,
533
- )
534
- return None
549
+ """Resolve the user's memory folder, creating the home if needed (UN-24823).
535
550
 
536
- root_scope_id = root_info.get("id")
537
- if not root_scope_id:
538
- logger.warning(
539
- "[user-memory] root folder lookup returned no id for %s",
540
- root_path,
541
- )
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:
542
565
  return None
543
-
544
- 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
+ )
545
572
 
546
573
 
547
574
  async def download_user_memory(