memuron 0.1.1__py3-none-any.whl
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.
- memuron/__init__.py +3 -0
- memuron/actions/__init__.py +12 -0
- memuron/actions/context.py +63 -0
- memuron/actions/helpers.py +88 -0
- memuron/actions/memory.py +340 -0
- memuron/actions/memory_write.py +290 -0
- memuron/actions/nodes.py +340 -0
- memuron/actions/registry.py +5 -0
- memuron/actions/runtime.py +37 -0
- memuron/actions/spaces_documents.py +720 -0
- memuron/actions/sync.py +155 -0
- memuron/application/__init__.py +1 -0
- memuron/application/api.py +206 -0
- memuron/application/app.py +103 -0
- memuron/application/capabilities.py +82 -0
- memuron/application/cli.py +35 -0
- memuron/application/config.py +176 -0
- memuron/application/mcp.py +44 -0
- memuron/application/mcp_oauth.py +290 -0
- memuron/application/registry.py +52 -0
- memuron/context.py +532 -0
- memuron/documents/__init__.py +1 -0
- memuron/documents/link_guardian.py +192 -0
- memuron/documents/linking.py +292 -0
- memuron/documents/parser.py +1152 -0
- memuron/documents/storage.py +151 -0
- memuron/documents/url_ingest.py +375 -0
- memuron/domain/__init__.py +1 -0
- memuron/domain/decoders.py +1 -0
- memuron/domain/encoders.py +185 -0
- memuron/domain/lifecycles.py +8 -0
- memuron/domain/limits.py +6 -0
- memuron/domain/representations.py +56 -0
- memuron/domain/schemas.py +581 -0
- memuron/domain/scope_filter.py +104 -0
- memuron/graphfs/__init__.py +1 -0
- memuron/graphfs/manual.py +635 -0
- memuron/graphfs/projection.py +578 -0
- memuron/graphfs/query.py +1782 -0
- memuron/graphfs/read_model.py +574 -0
- memuron/ingest/__init__.py +1 -0
- memuron/ingest/guardian.py +213 -0
- memuron/ingest/jobs.py +424 -0
- memuron/ingest/prompts.py +147 -0
- memuron/memory/__init__.py +1 -0
- memuron/memory/engine.py +35 -0
- memuron/memory/projections.py +452 -0
- memuron/memory/recipes.py +3247 -0
- memuron/persistence/__init__.py +1 -0
- memuron/persistence/db_pool.py +57 -0
- memuron/persistence/identity_store.py +918 -0
- memuron/persistence/store_helpers.py +16 -0
- memuron/search/__init__.py +1 -0
- memuron/search/fulltext.py +110 -0
- memuron/search/hybrid.py +284 -0
- memuron/search/pgvector.py +252 -0
- memuron/security/__init__.py +1 -0
- memuron/security/auth.py +143 -0
- memuron/security/auth_provider.py +119 -0
- memuron/security/authorization.py +53 -0
- memuron/security/clerk_scopes.py +94 -0
- memuron/security/clerk_webhooks.py +61 -0
- memuron/security/jwt_tokens.py +53 -0
- memuron/security/passwords.py +38 -0
- memuron/security/tenant.py +58 -0
- memuron/spaces/__init__.py +1 -0
- memuron/spaces/model.py +35 -0
- memuron/spaces/service.py +155 -0
- memuron/sync/__init__.py +25 -0
- memuron/sync/folder.py +828 -0
- memuron-0.1.1.dist-info/METADATA +242 -0
- memuron-0.1.1.dist-info/RECORD +74 -0
- memuron-0.1.1.dist-info/WHEEL +4 -0
- memuron-0.1.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Pydantic schemas for Memuron API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from memuron.domain.limits import MAX_ADD_MEMORY_CONTENT, MAX_MEMORY_CONTENT, MAX_SCOPE_ITEMS, MAX_SCOPE_TOKEN_LEN
|
|
10
|
+
from memuron.domain.scope_filter import parse_comma_scope, validate_scope_pattern
|
|
11
|
+
|
|
12
|
+
SOURCE_IDENTITY_METADATA_PATH = "metadata.system.source"
|
|
13
|
+
SOURCE_IDENTITY_FIELDS = ("custom_id", "session_id", "thread_id", "source_id", "source_url")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _clean_optional_string(value: Any) -> str | None:
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
text = str(value).strip()
|
|
20
|
+
return text or None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SourceIdentity(BaseModel):
|
|
24
|
+
"""Stable provenance keys shared by ingest, URL, session, and sync paths."""
|
|
25
|
+
|
|
26
|
+
custom_id: str | None = Field(None, max_length=512)
|
|
27
|
+
session_id: str | None = Field(None, max_length=512)
|
|
28
|
+
thread_id: str | None = Field(None, max_length=512)
|
|
29
|
+
source_id: str | None = Field(None, max_length=512)
|
|
30
|
+
source_url: str | None = Field(None, max_length=4096)
|
|
31
|
+
|
|
32
|
+
@field_validator("custom_id", "session_id", "thread_id", "source_id", "source_url", mode="before")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _strip_blank_strings(cls, value: Any) -> str | None:
|
|
35
|
+
return _clean_optional_string(value)
|
|
36
|
+
|
|
37
|
+
def as_metadata(self) -> dict[str, str]:
|
|
38
|
+
return {
|
|
39
|
+
key: value
|
|
40
|
+
for key, value in self.model_dump().items()
|
|
41
|
+
if isinstance(value, str) and value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def source_identity_from_metadata(metadata: dict[str, Any] | None) -> dict[str, str]:
|
|
46
|
+
if not isinstance(metadata, dict):
|
|
47
|
+
return {}
|
|
48
|
+
system = metadata.get("system")
|
|
49
|
+
if not isinstance(system, dict):
|
|
50
|
+
return {}
|
|
51
|
+
source = system.get("source")
|
|
52
|
+
if not isinstance(source, dict):
|
|
53
|
+
return {}
|
|
54
|
+
output: dict[str, str] = {}
|
|
55
|
+
for key in SOURCE_IDENTITY_FIELDS:
|
|
56
|
+
value = _clean_optional_string(source.get(key))
|
|
57
|
+
if value:
|
|
58
|
+
output[key] = value
|
|
59
|
+
return output
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def merge_source_identity_metadata(
|
|
63
|
+
metadata: dict[str, Any] | None = None,
|
|
64
|
+
*,
|
|
65
|
+
custom_id: str | None = None,
|
|
66
|
+
session_id: str | None = None,
|
|
67
|
+
thread_id: str | None = None,
|
|
68
|
+
source_id: str | None = None,
|
|
69
|
+
source_url: str | None = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
output = dict(metadata or {})
|
|
72
|
+
identity = SourceIdentity(
|
|
73
|
+
custom_id=custom_id,
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
thread_id=thread_id,
|
|
76
|
+
source_id=source_id,
|
|
77
|
+
source_url=source_url,
|
|
78
|
+
).as_metadata()
|
|
79
|
+
if not identity:
|
|
80
|
+
return output
|
|
81
|
+
system = dict(output.get("system") or {})
|
|
82
|
+
existing_source = system.get("source")
|
|
83
|
+
source = dict(existing_source) if isinstance(existing_source, dict) else {}
|
|
84
|
+
source.update(identity)
|
|
85
|
+
system["source"] = source
|
|
86
|
+
output["system"] = system
|
|
87
|
+
return output
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
MAX_EXTERNAL_ID_LEN = 512
|
|
91
|
+
MAX_SOURCE_URL_LEN = 4096
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AddMemoryRequest(BaseModel):
|
|
95
|
+
content: str = Field(..., max_length=MAX_ADD_MEMORY_CONTENT)
|
|
96
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
97
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
98
|
+
space_ref: str | None = Field(
|
|
99
|
+
None,
|
|
100
|
+
validation_alias=AliasChoices("space_ref", "space_id"),
|
|
101
|
+
description=(
|
|
102
|
+
"Target space UUID, slug, space.* token, or /spaces/space.* path; "
|
|
103
|
+
"defaults to the session default"
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
custom_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
107
|
+
session_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
108
|
+
thread_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
109
|
+
source_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
110
|
+
source_url: str | None = Field(None, max_length=MAX_SOURCE_URL_LEN)
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def _merge_tags_into_scope(cls, data: Any) -> Any:
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
return data
|
|
117
|
+
tags = data.get("tags")
|
|
118
|
+
scope = data.get("scope")
|
|
119
|
+
if tags is None:
|
|
120
|
+
return data
|
|
121
|
+
if scope is None:
|
|
122
|
+
data["scope"] = tags
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
@field_validator("scope")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _scope_string_lengths(cls, value: list[str] | None) -> list[str] | None:
|
|
128
|
+
if value is None:
|
|
129
|
+
return value
|
|
130
|
+
for token in value:
|
|
131
|
+
if len(token) > MAX_SCOPE_TOKEN_LEN:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Each scope token must be at most {MAX_SCOPE_TOKEN_LEN} characters"
|
|
134
|
+
)
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
@field_validator("custom_id", "session_id", "thread_id", "source_id", "source_url", mode="before")
|
|
138
|
+
@classmethod
|
|
139
|
+
def _source_identity_strings(cls, value: Any) -> str | None:
|
|
140
|
+
return _clean_optional_string(value)
|
|
141
|
+
|
|
142
|
+
def memory_metadata(self) -> dict[str, Any]:
|
|
143
|
+
return merge_source_identity_metadata(
|
|
144
|
+
self.metadata,
|
|
145
|
+
custom_id=self.custom_id,
|
|
146
|
+
session_id=self.session_id,
|
|
147
|
+
thread_id=self.thread_id,
|
|
148
|
+
source_id=self.source_id,
|
|
149
|
+
source_url=self.source_url,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class GetMemoriesRequest(BaseModel):
|
|
154
|
+
memory_ids: list[str]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class UpdateMemoryRequest(BaseModel):
|
|
158
|
+
content: str | None = Field(None, max_length=MAX_MEMORY_CONTENT)
|
|
159
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
160
|
+
|
|
161
|
+
@field_validator("scope")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _update_scope_lengths(cls, value: list[str] | None) -> list[str] | None:
|
|
164
|
+
if value is None:
|
|
165
|
+
return value
|
|
166
|
+
for token in value:
|
|
167
|
+
if len(token) > MAX_SCOPE_TOKEN_LEN:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Each scope token must be at most {MAX_SCOPE_TOKEN_LEN} characters"
|
|
170
|
+
)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class UnlinkMemoriesRequest(BaseModel):
|
|
175
|
+
memory_id_1: str
|
|
176
|
+
memory_id_2: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class BulkDeleteRequest(BaseModel):
|
|
180
|
+
scope: str = Field(..., min_length=1, description="Comma-separated scope patterns (required)")
|
|
181
|
+
confirm: bool = Field(..., description="Must be true to execute bulk delete")
|
|
182
|
+
|
|
183
|
+
@model_validator(mode="after")
|
|
184
|
+
def _validate_bulk_delete(self) -> "BulkDeleteRequest":
|
|
185
|
+
if not self.confirm:
|
|
186
|
+
raise ValueError("confirm must be true for bulk delete")
|
|
187
|
+
parse_comma_scope(self.scope)
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class MemoryResponse(BaseModel):
|
|
192
|
+
id: str
|
|
193
|
+
content: str
|
|
194
|
+
type: Literal["text", "image", "document", "collection"] = "text"
|
|
195
|
+
node_type: Literal["text", "image", "document", "collection"] = Field(
|
|
196
|
+
"text",
|
|
197
|
+
deprecated=True,
|
|
198
|
+
description="Deprecated alias for type. New clients should read type.",
|
|
199
|
+
)
|
|
200
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
201
|
+
perception: str | None = None
|
|
202
|
+
encoding: str = "memory"
|
|
203
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
204
|
+
custom_id: str | None = None
|
|
205
|
+
session_id: str | None = None
|
|
206
|
+
thread_id: str | None = None
|
|
207
|
+
source_id: str | None = None
|
|
208
|
+
source_url: str | None = None
|
|
209
|
+
scope: list[str]
|
|
210
|
+
links: list[str]
|
|
211
|
+
evolution_history: list[dict[str, Any]]
|
|
212
|
+
retrieval_count: int
|
|
213
|
+
timestamp: str
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class CreateMemoryResponse(BaseModel):
|
|
217
|
+
status: str = "success"
|
|
218
|
+
memory_id: str
|
|
219
|
+
action: str = "created"
|
|
220
|
+
memory: MemoryResponse | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
JobStatusType = Literal["queued", "processing", "completed", "failed"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class IngestJobAcceptedResponse(BaseModel):
|
|
227
|
+
status: str = "accepted"
|
|
228
|
+
job_id: str
|
|
229
|
+
job_status: JobStatusType
|
|
230
|
+
status_url: str
|
|
231
|
+
created_at: str
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class IngestJobErrorResponse(BaseModel):
|
|
235
|
+
code: str
|
|
236
|
+
message: str
|
|
237
|
+
retryable: bool = False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class IngestJobStatusResponse(BaseModel):
|
|
241
|
+
job_id: str
|
|
242
|
+
status: JobStatusType
|
|
243
|
+
created_at: str
|
|
244
|
+
started_at: str | None = None
|
|
245
|
+
completed_at: str | None = None
|
|
246
|
+
result: CreateMemoryResponse | None = None
|
|
247
|
+
error: IngestJobErrorResponse | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class GetMemoryResponse(BaseModel):
|
|
251
|
+
status: str = "success"
|
|
252
|
+
memory: MemoryResponse
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class GetMemoryViewResponse(BaseModel):
|
|
256
|
+
status: str = "success"
|
|
257
|
+
memory: dict[str, Any]
|
|
258
|
+
truncated_fields: list[str] = Field(default_factory=list)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class GetMemoriesResponse(BaseModel):
|
|
262
|
+
status: str = "success"
|
|
263
|
+
count: int
|
|
264
|
+
memories: list[MemoryResponse]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class UpdateMemoryResponse(BaseModel):
|
|
268
|
+
status: str = "success"
|
|
269
|
+
memory: MemoryResponse
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DeleteMemoryResponse(BaseModel):
|
|
273
|
+
status: str = "success"
|
|
274
|
+
message: str
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class UnlinkMemoriesResponse(BaseModel):
|
|
278
|
+
status: str = "success"
|
|
279
|
+
message: str
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class BulkDeleteResponse(BaseModel):
|
|
283
|
+
status: str = "success"
|
|
284
|
+
deleted_count: int
|
|
285
|
+
memory_ids: list[str]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class CountMemoriesResponse(BaseModel):
|
|
289
|
+
status: str = "success"
|
|
290
|
+
count: int
|
|
291
|
+
filters: dict[str, Any]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class ListMemoriesResponse(BaseModel):
|
|
295
|
+
status: str = "success"
|
|
296
|
+
count: int
|
|
297
|
+
memories: list[MemoryResponse]
|
|
298
|
+
filters: dict[str, Any] = Field(default_factory=dict)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class SearchMemoriesRequest(BaseModel):
|
|
302
|
+
query: str = Field(..., max_length=10_000)
|
|
303
|
+
k: int = Field(5, ge=1, le=100)
|
|
304
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
305
|
+
|
|
306
|
+
@field_validator("scope")
|
|
307
|
+
@classmethod
|
|
308
|
+
def _search_scope_patterns(cls, value: list[str] | None) -> list[str] | None:
|
|
309
|
+
if value is None:
|
|
310
|
+
return value
|
|
311
|
+
for token in value:
|
|
312
|
+
validate_scope_pattern(token)
|
|
313
|
+
return value
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class SearchResponse(BaseModel):
|
|
317
|
+
status: str = "success"
|
|
318
|
+
count: int
|
|
319
|
+
scope: list[str] | None = None
|
|
320
|
+
results: list[dict[str, Any]]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class ContextProfileRequest(BaseModel):
|
|
324
|
+
profile_id: str | None = None
|
|
325
|
+
memory_ids: list[str] = Field(default_factory=list)
|
|
326
|
+
custom_id: str | None = Field(None, max_length=512)
|
|
327
|
+
session_id: str | None = Field(None, max_length=512)
|
|
328
|
+
thread_id: str | None = Field(None, max_length=512)
|
|
329
|
+
source_id: str | None = Field(None, max_length=512)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class ContextProfileResponse(BaseModel):
|
|
333
|
+
status: str = "success"
|
|
334
|
+
profile_id: str
|
|
335
|
+
memories: list[MemoryResponse] = Field(default_factory=list)
|
|
336
|
+
source: SourceIdentity = Field(default_factory=SourceIdentity)
|
|
337
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class AssembleContextRequest(BaseModel):
|
|
341
|
+
query: str = Field(..., min_length=1, max_length=10_000)
|
|
342
|
+
k: int = Field(
|
|
343
|
+
8,
|
|
344
|
+
ge=1,
|
|
345
|
+
le=100,
|
|
346
|
+
validation_alias=AliasChoices("k", "limit"),
|
|
347
|
+
description="Maximum search hits to consider when assembling context.",
|
|
348
|
+
)
|
|
349
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
350
|
+
space_ref: str | None = Field(
|
|
351
|
+
None,
|
|
352
|
+
validation_alias=AliasChoices("space_ref", "space_id"),
|
|
353
|
+
description="Optional space UUID, slug, space.* token, or /spaces/space.* path.",
|
|
354
|
+
)
|
|
355
|
+
token_budget: int | None = Field(None, ge=50, le=50_000)
|
|
356
|
+
char_budget: int | None = Field(None, ge=200, le=200_000)
|
|
357
|
+
include_links: bool = True
|
|
358
|
+
include_breadcrumbs: bool = True
|
|
359
|
+
|
|
360
|
+
@field_validator("scope")
|
|
361
|
+
@classmethod
|
|
362
|
+
def _context_scope_patterns(cls, value: list[str] | None) -> list[str] | None:
|
|
363
|
+
if value is None:
|
|
364
|
+
return value
|
|
365
|
+
for token in value:
|
|
366
|
+
validate_scope_pattern(token)
|
|
367
|
+
return value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class AssembleContextResponse(BaseModel):
|
|
371
|
+
status: str = "success"
|
|
372
|
+
query: str
|
|
373
|
+
count: int
|
|
374
|
+
scope: list[str] | None = None
|
|
375
|
+
budget: dict[str, Any]
|
|
376
|
+
prompt_text: str
|
|
377
|
+
citations: list[dict[str, Any]]
|
|
378
|
+
items: list[dict[str, Any]]
|
|
379
|
+
truncated: dict[str, Any]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class SpaceProfileResponse(BaseModel):
|
|
383
|
+
status: str = "success"
|
|
384
|
+
space: dict[str, Any]
|
|
385
|
+
profile: dict[str, Any]
|
|
386
|
+
prompt_text: str
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class CollectionProfileResponse(BaseModel):
|
|
390
|
+
status: str = "success"
|
|
391
|
+
collection_id: str
|
|
392
|
+
profile: dict[str, Any]
|
|
393
|
+
prompt_text: str
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class CreateRichNodeRequest(BaseModel):
|
|
397
|
+
content: str = Field(..., max_length=MAX_ADD_MEMORY_CONTENT)
|
|
398
|
+
type: Literal["text", "image", "document", "collection"] | None = None
|
|
399
|
+
node_type: Literal["text", "image", "document", "collection"] = Field(
|
|
400
|
+
"text",
|
|
401
|
+
deprecated=True,
|
|
402
|
+
description="Deprecated input alias for type.",
|
|
403
|
+
)
|
|
404
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
405
|
+
perception: str | None = Field(None, max_length=MAX_ADD_MEMORY_CONTENT)
|
|
406
|
+
encoding: str = "memory"
|
|
407
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
408
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
409
|
+
auto_link: bool = True
|
|
410
|
+
custom_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
411
|
+
session_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
412
|
+
thread_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
413
|
+
source_id: str | None = Field(None, max_length=MAX_EXTERNAL_ID_LEN)
|
|
414
|
+
source_url: str | None = Field(None, max_length=MAX_SOURCE_URL_LEN)
|
|
415
|
+
|
|
416
|
+
@field_validator("custom_id", "session_id", "thread_id", "source_id", "source_url", mode="before")
|
|
417
|
+
@classmethod
|
|
418
|
+
def _source_identity_strings(cls, value: Any) -> str | None:
|
|
419
|
+
return _clean_optional_string(value)
|
|
420
|
+
|
|
421
|
+
def memory_metadata(self) -> dict[str, Any]:
|
|
422
|
+
return merge_source_identity_metadata(
|
|
423
|
+
self.metadata,
|
|
424
|
+
custom_id=self.custom_id,
|
|
425
|
+
session_id=self.session_id,
|
|
426
|
+
thread_id=self.thread_id,
|
|
427
|
+
source_id=self.source_id,
|
|
428
|
+
source_url=self.source_url,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class CreateCollectionRequest(BaseModel):
|
|
433
|
+
name: str = Field(..., min_length=1, max_length=200)
|
|
434
|
+
summary: str = Field(..., min_length=1, max_length=MAX_ADD_MEMORY_CONTENT)
|
|
435
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
436
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class CreatePlacementRequest(BaseModel):
|
|
440
|
+
child_id: str
|
|
441
|
+
name: str = Field(..., min_length=1, max_length=300)
|
|
442
|
+
scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
|
|
443
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
444
|
+
inherit_parent_scope: bool = True
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class PlacementResponse(BaseModel):
|
|
448
|
+
id: str
|
|
449
|
+
parent_id: str
|
|
450
|
+
child_id: str
|
|
451
|
+
name: str
|
|
452
|
+
scope: list[str] = Field(default_factory=list)
|
|
453
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
454
|
+
inherit_parent_scope: bool = True
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class CreateRichNodeResponse(BaseModel):
|
|
458
|
+
status: str = "success"
|
|
459
|
+
node: MemoryResponse
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class LinkRichNodeResponse(BaseModel):
|
|
463
|
+
status: str = "success"
|
|
464
|
+
memory_id: str
|
|
465
|
+
links_created: int
|
|
466
|
+
node: MemoryResponse
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class CreateNodeLinkRequest(BaseModel):
|
|
470
|
+
target_id: str | None = Field(
|
|
471
|
+
None,
|
|
472
|
+
description="Target memory id. Defaults to the source node, creating a self-loop alias.",
|
|
473
|
+
)
|
|
474
|
+
description: str = Field(..., min_length=1, max_length=10_000)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class CreateNodeLinkResponse(BaseModel):
|
|
478
|
+
status: str = "success"
|
|
479
|
+
memory_id: str
|
|
480
|
+
link: dict[str, Any]
|
|
481
|
+
created: bool
|
|
482
|
+
node: MemoryResponse
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class CreateCollectionResponse(BaseModel):
|
|
486
|
+
status: str = "success"
|
|
487
|
+
collection: MemoryResponse
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class CreatePlacementResponse(BaseModel):
|
|
491
|
+
status: str = "success"
|
|
492
|
+
placement: PlacementResponse
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class CollectionMembersResponse(BaseModel):
|
|
496
|
+
status: str = "success"
|
|
497
|
+
collection_id: str
|
|
498
|
+
count: int
|
|
499
|
+
members: list[dict[str, Any]]
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class DocumentIngestResponse(BaseModel):
|
|
503
|
+
status: str = "success"
|
|
504
|
+
document_key: str
|
|
505
|
+
custom_id: str | None = None
|
|
506
|
+
session_id: str | None = None
|
|
507
|
+
thread_id: str | None = None
|
|
508
|
+
source_id: str | None = None
|
|
509
|
+
source_url: str | None = None
|
|
510
|
+
source_type: str
|
|
511
|
+
media_type: str
|
|
512
|
+
file_name: str
|
|
513
|
+
page_count: int = 0
|
|
514
|
+
unreadable_pages: list[int] = Field(default_factory=list)
|
|
515
|
+
image_count: int = 0
|
|
516
|
+
graph_image_count: int = 0
|
|
517
|
+
skipped_image_count: int = 0
|
|
518
|
+
image_ids: list[str] = Field(default_factory=list)
|
|
519
|
+
source_object: dict[str, Any] | None = None
|
|
520
|
+
chunk_count: int
|
|
521
|
+
chunk_ids: list[str]
|
|
522
|
+
collection: MemoryResponse
|
|
523
|
+
document: MemoryResponse
|
|
524
|
+
images: list[MemoryResponse] = Field(default_factory=list)
|
|
525
|
+
chunks: list[MemoryResponse]
|
|
526
|
+
placements: list[PlacementResponse]
|
|
527
|
+
semantic_links_created: int = 0
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class DocumentSourceResponse(BaseModel):
|
|
531
|
+
status: str = "success"
|
|
532
|
+
requested_node_id: str
|
|
533
|
+
document_id: str
|
|
534
|
+
document_key: str
|
|
535
|
+
file_name: str
|
|
536
|
+
content_type: str
|
|
537
|
+
size_bytes: int
|
|
538
|
+
sha256: str
|
|
539
|
+
source_object: dict[str, Any]
|
|
540
|
+
download_url: str | None = None
|
|
541
|
+
expires_in_seconds: int | None = None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class GraphPathResponse(BaseModel):
|
|
545
|
+
status: str
|
|
546
|
+
path: list[str] | None = None
|
|
547
|
+
length: int | None = None
|
|
548
|
+
memories: list[dict[str, Any]] | None = None
|
|
549
|
+
message: str | None = None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class GraphHubsResponse(BaseModel):
|
|
553
|
+
status: str = "success"
|
|
554
|
+
hubs: list[dict[str, Any]]
|
|
555
|
+
total_memories: int
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class GraphNeighborhoodResponse(BaseModel):
|
|
559
|
+
status: str = "success"
|
|
560
|
+
center_memory_id: str
|
|
561
|
+
hops: int
|
|
562
|
+
neighborhood: list[dict[str, Any]]
|
|
563
|
+
total_in_neighborhood: int
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class GraphTraversalResponse(BaseModel):
|
|
567
|
+
status: str = "success"
|
|
568
|
+
start_memory_id: str
|
|
569
|
+
query: str
|
|
570
|
+
max_hops: int
|
|
571
|
+
edge_similarity_threshold: float
|
|
572
|
+
scope: list[str] | None = None
|
|
573
|
+
memories: list[dict[str, Any]]
|
|
574
|
+
traversed_edges: list[dict[str, Any]]
|
|
575
|
+
total_memories: int
|
|
576
|
+
total_edges: int
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class GraphExportResponse(BaseModel):
|
|
580
|
+
status: str = "success"
|
|
581
|
+
graph: dict[str, Any]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Safe scope glob matching (no user-supplied regex)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
|
|
8
|
+
from memuron.domain.limits import MAX_SCOPE_TOKEN_LEN
|
|
9
|
+
|
|
10
|
+
_UNSAFE_SCOPE_PATTERN = re.compile(r"[\\[\]?+^$|()]")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_scope_pattern(pattern: str) -> str:
|
|
14
|
+
pattern = pattern.strip()
|
|
15
|
+
if not pattern:
|
|
16
|
+
raise ValueError("scope pattern must be non-empty")
|
|
17
|
+
if len(pattern) > MAX_SCOPE_TOKEN_LEN:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Each scope pattern must be at most {MAX_SCOPE_TOKEN_LEN} characters"
|
|
20
|
+
)
|
|
21
|
+
if _UNSAFE_SCOPE_PATTERN.search(pattern):
|
|
22
|
+
raise ValueError("scope patterns may only use * as a wildcard")
|
|
23
|
+
return pattern
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_scope_patterns(raw: list[str] | None) -> list[str]:
|
|
27
|
+
patterns = [part.strip() for part in (raw or []) if part.strip()]
|
|
28
|
+
return [validate_scope_pattern(pattern) for pattern in patterns]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_comma_scope(scope: str | None) -> list[str]:
|
|
32
|
+
patterns = [part.strip() for part in (scope or "").split(",") if part.strip()]
|
|
33
|
+
return [validate_scope_pattern(pattern) for pattern in patterns]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scope_token_matches_pattern(token: str, pattern: str) -> bool:
|
|
37
|
+
token = token.strip()
|
|
38
|
+
pattern = pattern.strip()
|
|
39
|
+
if "*" in pattern:
|
|
40
|
+
return fnmatch(token.lower(), pattern.lower())
|
|
41
|
+
return token.lower() == pattern.lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scope_matches_filter(scope: list[str], scope_filter: list[str] | None) -> bool:
|
|
45
|
+
"""Match scope tokens against filter patterns.
|
|
46
|
+
|
|
47
|
+
- ``org:*`` and non-space patterns use AND (all must match).
|
|
48
|
+
- Multiple ``space.*`` patterns use OR (memory belongs to any listed space).
|
|
49
|
+
"""
|
|
50
|
+
if not scope_filter:
|
|
51
|
+
return True
|
|
52
|
+
tokens = [token.strip() for token in scope if token.strip()]
|
|
53
|
+
org_patterns = [p for p in scope_filter if str(p).startswith("org:")]
|
|
54
|
+
space_patterns = [p for p in scope_filter if str(p).startswith("space.")]
|
|
55
|
+
other_patterns = [
|
|
56
|
+
p
|
|
57
|
+
for p in scope_filter
|
|
58
|
+
if p not in org_patterns and p not in space_patterns
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
for pattern in org_patterns + other_patterns:
|
|
62
|
+
if not any(scope_token_matches_pattern(token, pattern) for token in tokens):
|
|
63
|
+
return False
|
|
64
|
+
if space_patterns:
|
|
65
|
+
if not any(
|
|
66
|
+
any(scope_token_matches_pattern(token, pattern) for token in tokens)
|
|
67
|
+
for pattern in space_patterns
|
|
68
|
+
):
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scope_sql_clause(column: str, pattern: str) -> tuple[str, str]:
|
|
74
|
+
validate_scope_pattern(pattern)
|
|
75
|
+
if "*" not in pattern:
|
|
76
|
+
return (
|
|
77
|
+
f"EXISTS (SELECT 1 FROM jsonb_array_elements_text({column}::jsonb) AS t "
|
|
78
|
+
"WHERE lower(t) = lower(?))",
|
|
79
|
+
pattern,
|
|
80
|
+
)
|
|
81
|
+
like = (
|
|
82
|
+
pattern.replace("\\", "\\\\")
|
|
83
|
+
.replace("%", "\\%")
|
|
84
|
+
.replace("_", "\\_")
|
|
85
|
+
.replace("*", "%")
|
|
86
|
+
)
|
|
87
|
+
return (
|
|
88
|
+
f"EXISTS (SELECT 1 FROM jsonb_array_elements_text({column}::jsonb) AS t "
|
|
89
|
+
"WHERE t ILIKE ? ESCAPE E'\\\\')",
|
|
90
|
+
like,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scope_sql_clauses(column: str, scope: list[str] | None) -> tuple[str, list[str]]:
|
|
95
|
+
patterns = normalize_scope_patterns(scope)
|
|
96
|
+
if not patterns:
|
|
97
|
+
return "", []
|
|
98
|
+
parts: list[str] = []
|
|
99
|
+
params: list[str] = []
|
|
100
|
+
for pattern in patterns:
|
|
101
|
+
clause, param = scope_sql_clause(column, pattern)
|
|
102
|
+
parts.append(clause)
|
|
103
|
+
params.append(param)
|
|
104
|
+
return " AND ".join(parts), params
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Graph-filesystem query language, manual, projections, and read model."""
|