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,720 @@
1
+ """Document ingest and space actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from time import perf_counter
8
+ from typing import Any
9
+
10
+ from artha_engine import ActionContext, ArthaEngine, HttpExposure
11
+ from fastapi import UploadFile
12
+ from pydantic import BaseModel, Field, field_validator
13
+
14
+ from memuron.actions.helpers import (
15
+ event_metadata,
16
+ merge_tenant_scope,
17
+ parse_metadata_form,
18
+ parse_scope_form,
19
+ require_memory_in_tenant,
20
+ require_user_org,
21
+ )
22
+ from memuron.actions.registry import actions
23
+ from memuron.application.config import settings
24
+ from memuron.context import space_profile
25
+ from memuron.documents.linking import link_document_after_ingest
26
+ from memuron.documents.parser import DocumentParseError
27
+ from memuron.documents.url_ingest import UrlIngestError, fetch_url_source
28
+ from memuron.graphfs.manual import filesystem_manual, manual_topics
29
+ from memuron.graphfs.query import FsQueryError, run_fs_query
30
+ from memuron.persistence.identity_store import IdentityStore
31
+ from memuron.memory.recipes import (
32
+ document_source_payload,
33
+ get_memory,
34
+ ingest_document_source,
35
+ link_memory_with_guardian,
36
+ )
37
+ from memuron.domain.schemas import (
38
+ DocumentIngestResponse,
39
+ DocumentSourceResponse,
40
+ MemoryResponse,
41
+ SpaceProfileResponse,
42
+ merge_source_identity_metadata,
43
+ )
44
+ from memuron.domain.limits import MAX_SCOPE_ITEMS, MAX_SCOPE_TOKEN_LEN
45
+ from memuron.spaces.service import (
46
+ guardian_space_context,
47
+ resolve_ingest_hint_space,
48
+ resolve_space_reference,
49
+ )
50
+ from memuron.security.tenant import merge_space_scope
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ class DocumentIngestInput(BaseModel):
56
+ model_config = {"arbitrary_types_allowed": True}
57
+
58
+ file: UploadFile
59
+ scope: str | None = None
60
+ metadata: str | None = None
61
+ custom_id: str | None = None
62
+ session_id: str | None = None
63
+ thread_id: str | None = None
64
+ source_id: str | None = None
65
+ source_url: str | None = None
66
+
67
+
68
+ class DocumentUrlIngestInput(BaseModel):
69
+ url: str = Field(..., min_length=1, max_length=4096)
70
+ scope: list[str] | None = Field(None, max_length=MAX_SCOPE_ITEMS)
71
+ space_ref: str | None = Field(None, max_length=500)
72
+ metadata: dict[str, Any] = Field(default_factory=dict)
73
+ custom_id: str | None = Field(None, max_length=512)
74
+ session_id: str | None = Field(None, max_length=512)
75
+ thread_id: str | None = Field(None, max_length=512)
76
+ source_id: str | None = Field(None, max_length=512)
77
+ source_url: str | None = Field(None, max_length=4096)
78
+
79
+ @field_validator("scope")
80
+ @classmethod
81
+ def _scope_token_lengths(cls, value: list[str] | None) -> list[str] | None:
82
+ if value is None:
83
+ return value
84
+ for token in value:
85
+ if len(token) > MAX_SCOPE_TOKEN_LEN:
86
+ raise ValueError(
87
+ f"Each scope token must be at most {MAX_SCOPE_TOKEN_LEN} characters"
88
+ )
89
+ return value
90
+
91
+
92
+ class CreateSpaceInput(BaseModel):
93
+ name: str = Field(..., min_length=1, max_length=120)
94
+ slug: str | None = Field(None, max_length=64)
95
+ description: str = Field("", max_length=500)
96
+ guardian_prompt: str = Field("", max_length=4000)
97
+ is_default: bool = False
98
+
99
+
100
+ class UpdateSpaceInput(BaseModel):
101
+ name: str | None = Field(None, min_length=1, max_length=120)
102
+ description: str | None = Field(None, max_length=500)
103
+ guardian_prompt: str | None = Field(None, max_length=4000)
104
+
105
+
106
+ class SetSpaceEnabledInput(BaseModel):
107
+ enabled: bool
108
+
109
+
110
+ class SpaceQueryInput(BaseModel):
111
+ query: str = Field(..., min_length=1, max_length=10_000)
112
+ cwd: str = Field("/spaces", min_length=1, max_length=500)
113
+
114
+
115
+ class SpaceResponse(BaseModel):
116
+ id: str
117
+ slug: str
118
+ name: str
119
+ token: str
120
+ description: str
121
+ guardian_prompt: str
122
+ is_default: bool
123
+ is_enabled: bool = False
124
+
125
+
126
+ class SpaceListResponse(BaseModel):
127
+ spaces: list[SpaceResponse]
128
+ count: int
129
+ enabled_count: int
130
+
131
+
132
+ class SpaceQueryResponse(BaseModel):
133
+ kind: str
134
+ protocol: str = "memuron.graph-filesystem.v1"
135
+ query: str
136
+ cwd: str
137
+ navigation: dict[str, Any] | None = None
138
+ manual: dict[str, Any] | None = None
139
+ items: list[dict[str, Any]]
140
+ count: int
141
+ trace: list[dict[str, Any]]
142
+ ast: list[dict[str, Any]]
143
+
144
+
145
+ def _space_response(space: dict[str, object]) -> SpaceResponse:
146
+ return SpaceResponse(
147
+ id=str(space["id"]),
148
+ slug=str(space["slug"]),
149
+ name=str(space["name"]),
150
+ token=str(space["token"]),
151
+ description=str(space["description"]),
152
+ guardian_prompt=str(space["guardian_prompt"]),
153
+ is_default=bool(space["is_default"]),
154
+ is_enabled=bool(space.get("is_enabled", False)),
155
+ )
156
+
157
+
158
+ async def _run_document_semantic_linking(
159
+ *,
160
+ payload: dict[str, Any],
161
+ engine: ArthaEngine,
162
+ context: ActionContext,
163
+ guardian: object,
164
+ identity: IdentityStore,
165
+ scoped: list[str],
166
+ meta: dict[str, object],
167
+ image_attachments: list[dict[str, Any]],
168
+ ) -> int:
169
+ semantic_links_created = 0
170
+ if payload.get("source_type") == "image":
171
+ document = payload.get("document") or {}
172
+ link_text = str(document.get("perception") or document.get("content") or "").strip()
173
+ if link_text and guardian is not None and settings.openrouter_api_key:
174
+ space_context = None
175
+ org_id = context.auth.tenant_id
176
+ user_id = str(context.auth.actor_id)
177
+ if org_id:
178
+ hint_space = resolve_ingest_hint_space(
179
+ identity,
180
+ user_id=user_id,
181
+ org_id=str(org_id),
182
+ space_id=None,
183
+ )
184
+ space_context = guardian_space_context(
185
+ identity,
186
+ user_id=user_id,
187
+ org_id=str(org_id),
188
+ hint_space=hint_space,
189
+ space_mode="assist",
190
+ )
191
+ try:
192
+ semantic_links_created = await link_memory_with_guardian(
193
+ engine,
194
+ guardian,
195
+ memory_id=str(document["id"]),
196
+ content=link_text,
197
+ scope=scoped,
198
+ event_metadata=meta,
199
+ space_context=space_context,
200
+ )
201
+ payload["document"] = get_memory(engine, str(document["id"]))
202
+ except Exception as exc:
203
+ logger.warning("Image ingest auto-link failed: %s", exc)
204
+ else:
205
+ try:
206
+ semantic_links_created = await link_document_after_ingest(
207
+ engine,
208
+ ingest_payload=payload,
209
+ scope=scoped,
210
+ event_metadata=meta,
211
+ image_attachments=image_attachments,
212
+ )
213
+ except Exception as exc:
214
+ logger.warning("Document ingest semantic linking failed: %s", exc)
215
+ return semantic_links_created
216
+
217
+
218
+ @actions.action(
219
+ name="document.ingest",
220
+ kind="write",
221
+ scopes=["memory:write"],
222
+ http=HttpExposure("POST", "/memuron/documents/ingest"),
223
+ multipart=True,
224
+ mcp=False,
225
+ cli=False,
226
+ inject={"guardian": "guardian", "identity": "identity"},
227
+ tags=["documents"],
228
+ )
229
+ async def document_ingest(
230
+ input: DocumentIngestInput,
231
+ engine: ArthaEngine,
232
+ context: ActionContext,
233
+ guardian: object,
234
+ identity: IdentityStore,
235
+ ) -> DocumentIngestResponse:
236
+ file_bytes = await input.file.read()
237
+ scoped = merge_tenant_scope(parse_scope_form(input.scope), context)
238
+ parsed_metadata = merge_source_identity_metadata(
239
+ parse_metadata_form(input.metadata),
240
+ custom_id=input.custom_id,
241
+ session_id=input.session_id,
242
+ thread_id=input.thread_id,
243
+ source_id=input.source_id,
244
+ source_url=input.source_url,
245
+ )
246
+ meta = event_metadata(context)
247
+ try:
248
+ payload = await asyncio.to_thread(
249
+ ingest_document_source,
250
+ engine,
251
+ file_name=input.file.filename or "document",
252
+ content_type=input.file.content_type,
253
+ file_bytes=file_bytes,
254
+ scope=scoped,
255
+ metadata=parsed_metadata,
256
+ event_metadata=meta,
257
+ )
258
+ except DocumentParseError as exc:
259
+ raise ValueError(str(exc)) from exc
260
+
261
+ image_attachments = payload.pop("image_attachments", [])
262
+ semantic_links_created = await _run_document_semantic_linking(
263
+ payload=payload,
264
+ engine=engine,
265
+ context=context,
266
+ guardian=guardian,
267
+ identity=identity,
268
+ scoped=scoped,
269
+ meta=meta,
270
+ image_attachments=image_attachments,
271
+ )
272
+
273
+ payload["semantic_links_created"] = semantic_links_created
274
+ return DocumentIngestResponse.model_validate(payload)
275
+
276
+
277
+ @actions.action(
278
+ name="document.ingest_url",
279
+ description="Fetch a URL, extract readable content, and ingest it as a Memuron document graph.",
280
+ kind="write",
281
+ scopes=["memory:write"],
282
+ http=HttpExposure("POST", "/memuron/documents/ingest-url"),
283
+ mcp=False,
284
+ cli=False,
285
+ inject={"guardian": "guardian", "identity": "identity"},
286
+ tags=["documents"],
287
+ )
288
+ async def document_ingest_url(
289
+ input: DocumentUrlIngestInput,
290
+ engine: ArthaEngine,
291
+ context: ActionContext,
292
+ guardian: object,
293
+ identity: IdentityStore,
294
+ ) -> DocumentIngestResponse:
295
+ user_id, org_id = require_user_org(context.auth)
296
+ scoped = merge_tenant_scope(input.scope, context)
297
+ if input.space_ref:
298
+ space = resolve_ingest_hint_space(
299
+ identity,
300
+ user_id=user_id,
301
+ org_id=org_id,
302
+ space_id=input.space_ref,
303
+ )
304
+ scoped = merge_space_scope(
305
+ scoped,
306
+ org_id=org_id,
307
+ space_token=str(space["token"]),
308
+ )
309
+
310
+ parsed_metadata = merge_source_identity_metadata(
311
+ dict(input.metadata or {}),
312
+ custom_id=input.custom_id,
313
+ session_id=input.session_id,
314
+ thread_id=input.thread_id,
315
+ source_id=input.source_id,
316
+ source_url=input.source_url or input.url,
317
+ )
318
+
319
+ try:
320
+ fetched = await asyncio.to_thread(fetch_url_source, input.url)
321
+ except UrlIngestError as exc:
322
+ raise ValueError(str(exc)) from exc
323
+
324
+ source_metadata = {
325
+ "url_ingest": True,
326
+ **fetched.metadata,
327
+ }
328
+ meta = {
329
+ **event_metadata(context),
330
+ "url_ingest": True,
331
+ "source_url": fetched.metadata.get("source_url") or input.url,
332
+ "fetched_url": fetched.metadata.get("fetched_url") or input.url,
333
+ }
334
+ try:
335
+ payload = await asyncio.to_thread(
336
+ ingest_document_source,
337
+ engine,
338
+ file_name=fetched.file_name,
339
+ content_type=fetched.content_type,
340
+ file_bytes=fetched.file_bytes,
341
+ scope=scoped,
342
+ metadata={
343
+ **parsed_metadata,
344
+ "url": source_metadata,
345
+ },
346
+ source_metadata=source_metadata,
347
+ event_metadata=meta,
348
+ )
349
+ except DocumentParseError as exc:
350
+ raise ValueError(str(exc)) from exc
351
+
352
+ image_attachments = payload.pop("image_attachments", [])
353
+ semantic_links_created = await _run_document_semantic_linking(
354
+ payload=payload,
355
+ engine=engine,
356
+ context=context,
357
+ guardian=guardian,
358
+ identity=identity,
359
+ scoped=scoped,
360
+ meta=meta,
361
+ image_attachments=image_attachments,
362
+ )
363
+ payload["semantic_links_created"] = semantic_links_created
364
+ return DocumentIngestResponse.model_validate(payload)
365
+
366
+
367
+ @actions.action(
368
+ name="document.source",
369
+ description=(
370
+ "Resolve a document, chunk, image, or document collection node to the original "
371
+ "uploaded source object and a short-lived download URL."
372
+ ),
373
+ kind="read",
374
+ scopes=["memory:read"],
375
+ http=HttpExposure("GET", "/memuron/documents/{node_id}/source"),
376
+ mcp="memuron_document_source",
377
+ cli="document source",
378
+ tags=["documents"],
379
+ )
380
+ def document_source(
381
+ node_id: str,
382
+ engine: ArthaEngine,
383
+ context: ActionContext,
384
+ ) -> DocumentSourceResponse:
385
+ require_memory_in_tenant(engine, node_id, context)
386
+ payload = document_source_payload(engine, node_id)
387
+ require_memory_in_tenant(engine, str(payload["document_id"]), context)
388
+ return DocumentSourceResponse.model_validate(payload)
389
+
390
+
391
+ @actions.action(
392
+ name="space.list",
393
+ description="List every space available to the active user and organization, including IDs, tokens, defaults, and enabled state.",
394
+ kind="read",
395
+ scopes=["memory:read"],
396
+ http=HttpExposure("GET", "/memuron/spaces"),
397
+ mcp="memuron_list_spaces",
398
+ cli="space list",
399
+ inject={"identity": "identity"},
400
+ tags=["spaces"],
401
+ )
402
+ def space_list(context: ActionContext, identity: IdentityStore) -> SpaceListResponse:
403
+ user_id, org_id = require_user_org(context.auth)
404
+ spaces = identity.list_user_org_spaces(user_id, org_id)
405
+ items = [_space_response(space) for space in spaces]
406
+ enabled_count = sum(1 for item in items if item.is_enabled)
407
+ return SpaceListResponse(spaces=items, count=len(items), enabled_count=enabled_count)
408
+
409
+
410
+ @actions.action(
411
+ name="space.query_manual",
412
+ description="Read the Memuron graph-filesystem manual. Call this first when learning query commands or recovering from a query error.",
413
+ kind="read",
414
+ scopes=["memory:read"],
415
+ http=HttpExposure("GET", "/memuron/spaces/query/manual"),
416
+ mcp="memuron_help",
417
+ cli="space manual",
418
+ tags=["spaces"],
419
+ )
420
+ def space_query_manual(context: ActionContext, topic: str = "overview") -> dict[str, Any]:
421
+ require_user_org(context.auth)
422
+ try:
423
+ manual = filesystem_manual(topic)
424
+ except KeyError as exc:
425
+ raise KeyError(
426
+ f"Unknown manual topic: {topic}. Topics: {', '.join(manual_topics())}"
427
+ ) from exc
428
+ return {
429
+ "protocol": "memuron.graph-filesystem.v1",
430
+ "endpoint": {
431
+ "method": "POST",
432
+ "path": "/memuron/spaces/query",
433
+ "body": {"cwd": "/spaces/space.personal", "query": "ls"},
434
+ },
435
+ "topics": manual_topics(),
436
+ "manual": manual,
437
+ }
438
+
439
+
440
+ @actions.action(
441
+ name="space.query",
442
+ description="Run a filesystem-style Memuron query or pipeline inside a space. Supports navigation, regex rg, semantic search, graph traversal, neighborhoods, paths, and related-memory discovery.",
443
+ kind="read",
444
+ scopes=["memory:read"],
445
+ http=HttpExposure("POST", "/memuron/spaces/query"),
446
+ mcp="memuron_query",
447
+ cli="query",
448
+ inject={"identity": "identity"},
449
+ tags=["spaces"],
450
+ )
451
+ def space_query(
452
+ input: SpaceQueryInput,
453
+ engine: ArthaEngine,
454
+ context: ActionContext,
455
+ identity: IdentityStore,
456
+ ) -> SpaceQueryResponse:
457
+ user_id, org_id = require_user_org(context.auth)
458
+ spaces = identity.list_user_org_spaces(user_id, org_id)
459
+ try:
460
+ result = run_fs_query(
461
+ engine,
462
+ query=input.query,
463
+ cwd=input.cwd,
464
+ spaces=spaces,
465
+ org_id=org_id,
466
+ )
467
+ except FsQueryError as exc:
468
+ raise ValueError(exc.detail(query=input.query, cwd=input.cwd)) from exc
469
+ return SpaceQueryResponse.model_validate(result)
470
+
471
+
472
+ @actions.action(
473
+ name="space.profile",
474
+ description=(
475
+ "Return a lightweight deterministic profile for one space, including "
476
+ "counts, collections, previews, and a prompt-ready block."
477
+ ),
478
+ kind="read",
479
+ scopes=["memory:read"],
480
+ http=HttpExposure("GET", "/memuron/spaces/{space_id}/profile"),
481
+ mcp=False,
482
+ cli=False,
483
+ inject={"identity": "identity"},
484
+ tags=["spaces"],
485
+ )
486
+ def space_profile_action(
487
+ space_id: str,
488
+ engine: ArthaEngine,
489
+ context: ActionContext,
490
+ identity: IdentityStore,
491
+ ) -> SpaceProfileResponse:
492
+ user_id, org_id = require_user_org(context.auth)
493
+ space = resolve_space_reference(identity, org_id=org_id, space_ref=space_id)
494
+ if space is None:
495
+ raise KeyError("Space not found")
496
+ enabled = False
497
+ for candidate in identity.list_user_org_spaces(user_id, org_id):
498
+ if candidate["id"] == space["id"]:
499
+ space = candidate
500
+ enabled = bool(candidate.get("is_enabled"))
501
+ break
502
+ payload = space_profile(engine, space=space, org_id=org_id)
503
+ return SpaceProfileResponse.model_validate(
504
+ {
505
+ "space": {**space, "is_enabled": enabled},
506
+ "profile": payload["profile"],
507
+ "prompt_text": payload["prompt_text"],
508
+ }
509
+ )
510
+
511
+
512
+ @actions.action(
513
+ name="space.create",
514
+ description="Create a space in the active organization and optionally make it the organization default.",
515
+ kind="write",
516
+ scopes=["space:admin"],
517
+ http=HttpExposure("POST", "/memuron/spaces"),
518
+ mcp="memuron_create_space",
519
+ cli="space create",
520
+ tags=["spaces"],
521
+ inject={"identity": "identity"},
522
+ )
523
+ def space_create(
524
+ input: CreateSpaceInput,
525
+ context: ActionContext,
526
+ identity: IdentityStore,
527
+ ) -> SpaceResponse:
528
+ user_id, org_id = require_user_org(context.auth)
529
+ identity.ensure_org_spaces(org_id, user_id)
530
+ space = identity.create_space(
531
+ org_id=org_id,
532
+ name=input.name,
533
+ slug=input.slug,
534
+ description=input.description,
535
+ guardian_prompt=input.guardian_prompt,
536
+ is_default=input.is_default,
537
+ )
538
+ identity.seed_space_pref_for_user(
539
+ user_id,
540
+ space["id"],
541
+ enabled=bool(input.is_default),
542
+ )
543
+ enabled = bool(input.is_default)
544
+ if not enabled:
545
+ for member in identity.list_user_org_spaces(user_id, org_id):
546
+ if member["id"] == space["id"]:
547
+ enabled = bool(member.get("is_enabled"))
548
+ break
549
+ return _space_response({**space, "is_enabled": enabled})
550
+
551
+
552
+ @actions.action(
553
+ name="space.update",
554
+ description="Update a space by UUID, slug, token such as space.work, or path such as /spaces/space.work.",
555
+ kind="write",
556
+ scopes=["space:admin"],
557
+ http=HttpExposure("PATCH", "/memuron/spaces/{space_id}"),
558
+ mcp=False,
559
+ cli="space update",
560
+ tags=["spaces"],
561
+ inject={"identity": "identity"},
562
+ )
563
+ def space_update(
564
+ space_id: str,
565
+ input: UpdateSpaceInput,
566
+ context: ActionContext,
567
+ identity: IdentityStore,
568
+ ) -> SpaceResponse:
569
+ user_id, org_id = require_user_org(context.auth)
570
+ existing = resolve_space_reference(identity, org_id=org_id, space_ref=space_id)
571
+ if existing is None:
572
+ raise KeyError("Space not found")
573
+ resolved_id = str(existing["id"])
574
+ identity.update_space(
575
+ resolved_id,
576
+ name=input.name,
577
+ description=input.description,
578
+ guardian_prompt=input.guardian_prompt,
579
+ )
580
+ for member in identity.list_user_org_spaces(user_id, org_id):
581
+ if member["id"] == resolved_id:
582
+ return _space_response(member)
583
+ updated = identity.get_space_by_id(resolved_id)
584
+ if updated is None:
585
+ raise KeyError("Space not found")
586
+ return _space_response({**updated, "is_enabled": False})
587
+
588
+
589
+ @actions.action(
590
+ name="space.update_mcp",
591
+ description="Update a space by UUID, slug, space.* token, or /spaces path using flat editable fields.",
592
+ kind="write",
593
+ scopes=["space:admin"],
594
+ mcp="memuron_update_space",
595
+ cli=False,
596
+ tags=["spaces"],
597
+ inject={"identity": "identity"},
598
+ )
599
+ def space_update_mcp(
600
+ space_ref: str,
601
+ context: ActionContext,
602
+ identity: IdentityStore,
603
+ name: str | None = None,
604
+ description: str | None = None,
605
+ guardian_prompt: str | None = None,
606
+ ) -> SpaceResponse:
607
+ return space_update(
608
+ space_ref,
609
+ UpdateSpaceInput(
610
+ name=name,
611
+ description=description,
612
+ guardian_prompt=guardian_prompt,
613
+ ),
614
+ context,
615
+ identity,
616
+ )
617
+
618
+
619
+ @actions.action(
620
+ name="space.set_default",
621
+ description="Make a space the organization default using its UUID, slug, token, or /spaces path.",
622
+ kind="write",
623
+ scopes=["space:admin"],
624
+ http=HttpExposure("POST", "/memuron/spaces/{space_id}/set-default"),
625
+ mcp=False,
626
+ cli="space set-default",
627
+ tags=["spaces"],
628
+ inject={"identity": "identity"},
629
+ )
630
+ def space_set_default(
631
+ space_id: str,
632
+ context: ActionContext,
633
+ identity: IdentityStore,
634
+ ) -> SpaceResponse:
635
+ user_id, org_id = require_user_org(context.auth)
636
+ space = resolve_space_reference(identity, org_id=org_id, space_ref=space_id)
637
+ if space is None:
638
+ raise KeyError("Space not found")
639
+ resolved_id = str(space["id"])
640
+ identity.set_default_space(org_id, resolved_id)
641
+ for member in identity.list_user_org_spaces(user_id, org_id):
642
+ if member["id"] == resolved_id:
643
+ return _space_response(member)
644
+ raise KeyError("Space not found")
645
+
646
+
647
+ @actions.action(
648
+ name="space.set_default_mcp",
649
+ description="Make a space the organization default using its UUID, slug, space.* token, or /spaces path.",
650
+ kind="write",
651
+ scopes=["space:admin"],
652
+ mcp="memuron_set_default_space",
653
+ cli=False,
654
+ tags=["spaces"],
655
+ inject={"identity": "identity"},
656
+ )
657
+ def space_set_default_mcp(
658
+ space_ref: str,
659
+ context: ActionContext,
660
+ identity: IdentityStore,
661
+ ) -> SpaceResponse:
662
+ return space_set_default(space_ref, context, identity)
663
+
664
+
665
+ @actions.action(
666
+ name="space.set_enabled",
667
+ description="Enable or disable a space by UUID, slug, token, or /spaces path without deleting it.",
668
+ kind="write",
669
+ scopes=["memory:write"],
670
+ http=HttpExposure("POST", "/memuron/spaces/{space_id}/set-enabled"),
671
+ mcp=False,
672
+ cli="space set-enabled",
673
+ tags=["spaces"],
674
+ inject={"identity": "identity"},
675
+ )
676
+ def space_set_enabled(
677
+ space_id: str,
678
+ input: SetSpaceEnabledInput,
679
+ context: ActionContext,
680
+ identity: IdentityStore,
681
+ ) -> SpaceResponse:
682
+ user_id, org_id = require_user_org(context.auth)
683
+ space = resolve_space_reference(identity, org_id=org_id, space_ref=space_id)
684
+ if space is None:
685
+ raise KeyError("Space not found")
686
+ resolved_id = str(space["id"])
687
+ identity.set_space_enabled(
688
+ user_id,
689
+ org_id,
690
+ resolved_id,
691
+ enabled=input.enabled,
692
+ )
693
+ for member in identity.list_user_org_spaces(user_id, org_id):
694
+ if member["id"] == resolved_id:
695
+ return _space_response(member)
696
+ raise KeyError("Space not found")
697
+
698
+
699
+ @actions.action(
700
+ name="space.set_enabled_mcp",
701
+ description="Enable or disable a space by UUID, slug, space.* token, or /spaces path.",
702
+ kind="write",
703
+ scopes=["memory:write"],
704
+ mcp="memuron_set_space_enabled",
705
+ cli=False,
706
+ tags=["spaces"],
707
+ inject={"identity": "identity"},
708
+ )
709
+ def space_set_enabled_mcp(
710
+ space_ref: str,
711
+ enabled: bool,
712
+ context: ActionContext,
713
+ identity: IdentityStore,
714
+ ) -> SpaceResponse:
715
+ return space_set_enabled(
716
+ space_ref,
717
+ SetSpaceEnabledInput(enabled=enabled),
718
+ context,
719
+ identity,
720
+ )