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.
Files changed (74) hide show
  1. memuron/__init__.py +3 -0
  2. memuron/actions/__init__.py +12 -0
  3. memuron/actions/context.py +63 -0
  4. memuron/actions/helpers.py +88 -0
  5. memuron/actions/memory.py +340 -0
  6. memuron/actions/memory_write.py +290 -0
  7. memuron/actions/nodes.py +340 -0
  8. memuron/actions/registry.py +5 -0
  9. memuron/actions/runtime.py +37 -0
  10. memuron/actions/spaces_documents.py +720 -0
  11. memuron/actions/sync.py +155 -0
  12. memuron/application/__init__.py +1 -0
  13. memuron/application/api.py +206 -0
  14. memuron/application/app.py +103 -0
  15. memuron/application/capabilities.py +82 -0
  16. memuron/application/cli.py +35 -0
  17. memuron/application/config.py +176 -0
  18. memuron/application/mcp.py +44 -0
  19. memuron/application/mcp_oauth.py +290 -0
  20. memuron/application/registry.py +52 -0
  21. memuron/context.py +532 -0
  22. memuron/documents/__init__.py +1 -0
  23. memuron/documents/link_guardian.py +192 -0
  24. memuron/documents/linking.py +292 -0
  25. memuron/documents/parser.py +1152 -0
  26. memuron/documents/storage.py +151 -0
  27. memuron/documents/url_ingest.py +375 -0
  28. memuron/domain/__init__.py +1 -0
  29. memuron/domain/decoders.py +1 -0
  30. memuron/domain/encoders.py +185 -0
  31. memuron/domain/lifecycles.py +8 -0
  32. memuron/domain/limits.py +6 -0
  33. memuron/domain/representations.py +56 -0
  34. memuron/domain/schemas.py +581 -0
  35. memuron/domain/scope_filter.py +104 -0
  36. memuron/graphfs/__init__.py +1 -0
  37. memuron/graphfs/manual.py +635 -0
  38. memuron/graphfs/projection.py +578 -0
  39. memuron/graphfs/query.py +1782 -0
  40. memuron/graphfs/read_model.py +574 -0
  41. memuron/ingest/__init__.py +1 -0
  42. memuron/ingest/guardian.py +213 -0
  43. memuron/ingest/jobs.py +424 -0
  44. memuron/ingest/prompts.py +147 -0
  45. memuron/memory/__init__.py +1 -0
  46. memuron/memory/engine.py +35 -0
  47. memuron/memory/projections.py +452 -0
  48. memuron/memory/recipes.py +3247 -0
  49. memuron/persistence/__init__.py +1 -0
  50. memuron/persistence/db_pool.py +57 -0
  51. memuron/persistence/identity_store.py +918 -0
  52. memuron/persistence/store_helpers.py +16 -0
  53. memuron/search/__init__.py +1 -0
  54. memuron/search/fulltext.py +110 -0
  55. memuron/search/hybrid.py +284 -0
  56. memuron/search/pgvector.py +252 -0
  57. memuron/security/__init__.py +1 -0
  58. memuron/security/auth.py +143 -0
  59. memuron/security/auth_provider.py +119 -0
  60. memuron/security/authorization.py +53 -0
  61. memuron/security/clerk_scopes.py +94 -0
  62. memuron/security/clerk_webhooks.py +61 -0
  63. memuron/security/jwt_tokens.py +53 -0
  64. memuron/security/passwords.py +38 -0
  65. memuron/security/tenant.py +58 -0
  66. memuron/spaces/__init__.py +1 -0
  67. memuron/spaces/model.py +35 -0
  68. memuron/spaces/service.py +155 -0
  69. memuron/sync/__init__.py +25 -0
  70. memuron/sync/folder.py +828 -0
  71. memuron-0.1.1.dist-info/METADATA +242 -0
  72. memuron-0.1.1.dist-info/RECORD +74 -0
  73. memuron-0.1.1.dist-info/WHEEL +4 -0
  74. 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."""