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.
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/PKG-INFO +13 -8
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/README.md +10 -5
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/pyproject.toml +3 -3
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/config.py +8 -14
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/tests/test_user_memory.py +141 -74
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory.py +115 -88
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory_message_log.py +0 -0
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory_postprocessor.py +0 -0
- {unique_user_memory-2026.36.0.dev3 → unique_user_memory-2026.36.0.dev4}/unique_user_memory/user_memory_prompts.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.dev3 → 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
|
)
|
|
@@ -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
|
|
1192
|
+
async def test_ensure_user_memory_folder_returns_existing_home_folder(
|
|
1172
1193
|
monkeypatch: pytest.MonkeyPatch,
|
|
1173
1194
|
) -> None:
|
|
1174
|
-
|
|
1175
|
-
|
|
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.
|
|
1193
|
-
|
|
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.
|
|
1197
|
-
|
|
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 == "
|
|
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
|
|
1266
|
+
async def test_ensure_user_memory_folder_creates_home_when_neither_location_exists(
|
|
1225
1267
|
monkeypatch: pytest.MonkeyPatch,
|
|
1226
1268
|
) -> None:
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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 == "
|
|
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
|
-
|
|
1260
|
-
relativePaths=["user_1"],
|
|
1305
|
+
paths=["/home-user_1/user-memory"],
|
|
1261
1306
|
inheritAccess=False,
|
|
1262
1307
|
)
|
|
1263
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
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
|
|
1336
|
+
async def test_ensure_user_memory_folder_does_not_provision_on_unverified_lookup_failure(
|
|
1277
1337
|
monkeypatch: pytest.MonkeyPatch,
|
|
1278
1338
|
) -> None:
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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.
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|