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,290 @@
1
+ """Memory write/delete and ingest job actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from artha_engine import ActionContext, ArthaEngine, HttpExposure
6
+
7
+ from memuron.actions.helpers import (
8
+ event_metadata,
9
+ merge_tenant_scope,
10
+ require_memory_in_tenant,
11
+ require_user_org,
12
+ )
13
+ from memuron.actions.registry import actions
14
+ from memuron.application.config import settings
15
+ from memuron.persistence.identity_store import IdentityStore
16
+ from memuron.ingest.jobs import build_job_payload
17
+ from memuron.memory.recipes import (
18
+ bulk_delete_memories,
19
+ delete_memory,
20
+ unlink_memories,
21
+ update_memory,
22
+ )
23
+ from memuron.domain.schemas import (
24
+ AddMemoryRequest,
25
+ BulkDeleteRequest,
26
+ BulkDeleteResponse,
27
+ CreateMemoryResponse,
28
+ DeleteMemoryResponse,
29
+ IngestJobAcceptedResponse,
30
+ IngestJobErrorResponse,
31
+ IngestJobStatusResponse,
32
+ MemoryResponse,
33
+ UnlinkMemoriesRequest,
34
+ UnlinkMemoriesResponse,
35
+ UpdateMemoryRequest,
36
+ UpdateMemoryResponse,
37
+ )
38
+ from memuron.spaces.service import guardian_space_context, resolve_ingest_hint_space
39
+ from memuron.security.tenant import merge_org_scope, merge_space_scope
40
+
41
+
42
+ @actions.action(
43
+ name="memory.ingest",
44
+ description="Queue a memory for Guardian ingest into the active organization and optional space. Poll the returned job ID with memuron_job_status.",
45
+ kind="write",
46
+ scopes=["memory:write"],
47
+ http=HttpExposure("POST", "/memuron/memories", status_code=202),
48
+ mcp="memuron_ingest",
49
+ cli="memory ingest",
50
+ inject={"identity": "identity", "jobs": "jobs"},
51
+ tags=["memory"],
52
+ )
53
+ def memory_ingest(
54
+ input: AddMemoryRequest,
55
+ context: ActionContext,
56
+ identity: IdentityStore,
57
+ jobs: object,
58
+ ) -> IngestJobAcceptedResponse:
59
+ if jobs is None:
60
+ raise RuntimeError("Ingest job store is unavailable (PostgreSQL required)")
61
+ user_id, org_id = require_user_org(context.auth)
62
+ space = None
63
+ space_context = None
64
+ candidate_scope = None
65
+ explicit_space_pick = input.space_ref is not None
66
+ space = resolve_ingest_hint_space(
67
+ identity,
68
+ user_id=user_id,
69
+ org_id=org_id,
70
+ space_id=input.space_ref,
71
+ )
72
+ space_mode = "strict" if explicit_space_pick else "assist"
73
+ space_context = guardian_space_context(
74
+ identity,
75
+ user_id=user_id,
76
+ org_id=org_id,
77
+ hint_space=space,
78
+ space_mode=space_mode,
79
+ )
80
+ candidate_scope = merge_org_scope(None, org_id)
81
+ if space_mode == "strict" and space:
82
+ candidate_scope = merge_space_scope(
83
+ candidate_scope,
84
+ org_id=org_id,
85
+ space_token=str(space["token"]),
86
+ )
87
+ ingest_scope = merge_org_scope(input.scope, org_id)
88
+ if explicit_space_pick and space:
89
+ ingest_scope = merge_space_scope(
90
+ ingest_scope,
91
+ org_id=org_id,
92
+ space_token=str(space["token"]),
93
+ )
94
+ payload = build_job_payload(
95
+ input.content,
96
+ ingest_scope,
97
+ metadata=input.memory_metadata(),
98
+ event_metadata=event_metadata(context, space_context=space_context),
99
+ space_context=space_context,
100
+ candidate_scope=candidate_scope,
101
+ )
102
+ job = jobs.create_job(payload, max_attempts=settings.ingest_job_max_attempts) # type: ignore[union-attr]
103
+ return IngestJobAcceptedResponse(
104
+ job_id=job["id"],
105
+ job_status=job["status"],
106
+ status_url=f"/memuron/jobs/{job['id']}",
107
+ created_at=job["created_at"],
108
+ )
109
+
110
+
111
+ @actions.action(
112
+ name="memory.job_status",
113
+ description="Poll an ingest job created by memuron_ingest. Returns queued, processing, completed, or failed state.",
114
+ kind="read",
115
+ scopes=["memory:read"],
116
+ http=HttpExposure("GET", "/memuron/jobs/{job_id}"),
117
+ mcp="memuron_job_status",
118
+ cli="memory job-status",
119
+ tags=["memory"],
120
+ inject={"jobs": "jobs"},
121
+ )
122
+ def memory_job_status(
123
+ job_id: str,
124
+ context: ActionContext,
125
+ jobs: object,
126
+ ) -> IngestJobStatusResponse:
127
+ if jobs is None:
128
+ raise RuntimeError("Ingest job store is unavailable (PostgreSQL required)")
129
+ job = jobs.get_job(job_id) # type: ignore[union-attr]
130
+ if job is None:
131
+ raise KeyError("Job not found")
132
+ payload = job.get("payload_json") or {}
133
+ tenant_id = (payload.get("event_metadata") or {}).get("tenant_id")
134
+ if tenant_id != context.auth.tenant_id:
135
+ raise KeyError("Job not found")
136
+ result = None
137
+ if job.get("result_json"):
138
+ result = CreateMemoryResponse.model_validate(job["result_json"])
139
+ error = None
140
+ if job.get("error_json"):
141
+ error = IngestJobErrorResponse.model_validate(job["error_json"])
142
+ return IngestJobStatusResponse(
143
+ job_id=job["id"],
144
+ status=job["status"],
145
+ created_at=job["created_at"],
146
+ started_at=job.get("started_at"),
147
+ completed_at=job.get("completed_at"),
148
+ result=result,
149
+ error=error,
150
+ )
151
+
152
+
153
+ @actions.action(
154
+ name="memory.update",
155
+ description="Update the content or scope of a known memory in the active organization.",
156
+ kind="write",
157
+ scopes=["memory:write"],
158
+ http=HttpExposure("PUT", "/memuron/memories/{memory_id}"),
159
+ mcp=False,
160
+ cli="memory update",
161
+ tags=["memory"],
162
+ )
163
+ def memory_update(
164
+ memory_id: str,
165
+ input: UpdateMemoryRequest,
166
+ engine: ArthaEngine,
167
+ context: ActionContext,
168
+ ) -> UpdateMemoryResponse:
169
+ require_memory_in_tenant(engine, memory_id, context)
170
+ if input.content is None and input.scope is None:
171
+ raise ValueError("Provide content and/or scope to update")
172
+ memory = update_memory(
173
+ engine,
174
+ memory_id,
175
+ content=input.content,
176
+ scope=merge_tenant_scope(input.scope, context) if input.scope is not None else None,
177
+ event_metadata=event_metadata(context),
178
+ )
179
+ return UpdateMemoryResponse(memory=MemoryResponse.model_validate(memory))
180
+
181
+
182
+ @actions.action(
183
+ name="memory.update_mcp",
184
+ description="Update a known memory with flat content and scope arguments. Provide at least one field.",
185
+ kind="write",
186
+ scopes=["memory:write"],
187
+ mcp="memuron_update",
188
+ cli=False,
189
+ tags=["memory"],
190
+ )
191
+ def memory_update_mcp(
192
+ memory_id: str,
193
+ engine: ArthaEngine,
194
+ context: ActionContext,
195
+ content: str | None = None,
196
+ scope: list[str] | None = None,
197
+ ) -> UpdateMemoryResponse:
198
+ require_memory_in_tenant(engine, memory_id, context)
199
+ if content is None and scope is None:
200
+ raise ValueError("Provide content and/or scope to update")
201
+ memory = update_memory(
202
+ engine,
203
+ memory_id,
204
+ content=content,
205
+ scope=merge_tenant_scope(scope, context) if scope is not None else None,
206
+ event_metadata=event_metadata(context),
207
+ )
208
+ return UpdateMemoryResponse(memory=MemoryResponse.model_validate(memory))
209
+
210
+
211
+ @actions.action(
212
+ name="memory.delete",
213
+ description="Permanently delete one known memory from the active organization. Use only when the user clearly requested deletion.",
214
+ kind="destructive",
215
+ scopes=["memory:delete"],
216
+ http=HttpExposure("DELETE", "/memuron/memories/{memory_id}"),
217
+ mcp="memuron_delete",
218
+ cli="memory delete",
219
+ tags=["memory"],
220
+ )
221
+ def memory_delete(
222
+ memory_id: str,
223
+ engine: ArthaEngine,
224
+ context: ActionContext,
225
+ confirm: bool = False,
226
+ ) -> DeleteMemoryResponse:
227
+ if context.transport == "mcp" and not confirm:
228
+ raise ValueError("confirm must be true to delete a memory through MCP")
229
+ require_memory_in_tenant(engine, memory_id, context)
230
+ deleted = delete_memory(engine, memory_id, event_metadata=event_metadata(context))
231
+ if not deleted:
232
+ raise KeyError(f"Memory not found: {memory_id}")
233
+ return DeleteMemoryResponse(message=f"Memory {memory_id} deleted")
234
+
235
+
236
+ @actions.action(
237
+ name="memory.bulk_delete",
238
+ description="Permanently delete memories matching a scope filter after explicit confirmation.",
239
+ kind="destructive",
240
+ scopes=["memory:delete"],
241
+ http=HttpExposure("POST", "/memuron/memories/bulk-delete"),
242
+ mcp=False,
243
+ cli="memory bulk-delete",
244
+ tags=["memory"],
245
+ )
246
+ def memory_bulk_delete(
247
+ input: BulkDeleteRequest,
248
+ engine: ArthaEngine,
249
+ context: ActionContext,
250
+ ) -> BulkDeleteResponse:
251
+ deleted_count, memory_ids = bulk_delete_memories(
252
+ engine,
253
+ scope=tenant_scope_query(context, input.scope) or input.scope,
254
+ event_metadata=event_metadata(context),
255
+ )
256
+ return BulkDeleteResponse(deleted_count=deleted_count, memory_ids=memory_ids)
257
+
258
+
259
+ @actions.action(
260
+ name="memory.unlink",
261
+ description="Remove semantic links between two known memories in the active organization.",
262
+ kind="write",
263
+ scopes=["memory:write"],
264
+ http=HttpExposure("POST", "/memuron/memories/unlink"),
265
+ mcp=False,
266
+ cli="memory unlink",
267
+ tags=["memory"],
268
+ )
269
+ def memory_unlink(
270
+ input: UnlinkMemoriesRequest,
271
+ engine: ArthaEngine,
272
+ context: ActionContext,
273
+ ) -> UnlinkMemoriesResponse:
274
+ require_memory_in_tenant(engine, input.memory_id_1, context)
275
+ require_memory_in_tenant(engine, input.memory_id_2, context)
276
+ removed = unlink_memories(
277
+ engine,
278
+ input.memory_id_1,
279
+ input.memory_id_2,
280
+ event_metadata=event_metadata(context),
281
+ )
282
+ return UnlinkMemoriesResponse(
283
+ message=f"Removed {removed} link(s) between {input.memory_id_1} and {input.memory_id_2}"
284
+ )
285
+
286
+
287
+ def tenant_scope_query(context: ActionContext, scope: str) -> str | None:
288
+ from memuron.actions.helpers import tenant_scope_query as _query
289
+
290
+ return _query(context, scope)
@@ -0,0 +1,340 @@
1
+ """Rich node and collection actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from artha_engine import ActionContext, ArthaEngine, HttpExposure
8
+
9
+ from memuron.actions.helpers import (
10
+ event_metadata,
11
+ merge_tenant_scope,
12
+ require_memory_in_tenant,
13
+ )
14
+ from memuron.actions.registry import actions
15
+ from memuron.application.config import settings
16
+ from memuron.context import collection_profile
17
+ from memuron.persistence.identity_store import IdentityStore
18
+ from memuron.memory.recipes import (
19
+ collection_members,
20
+ create_collection,
21
+ create_memory_link,
22
+ create_rich_node,
23
+ get_memory,
24
+ link_memory_with_guardian,
25
+ place_node_in_collection,
26
+ )
27
+ from memuron.domain.schemas import (
28
+ CollectionMembersResponse,
29
+ CollectionProfileResponse,
30
+ CreateCollectionRequest,
31
+ CreateNodeLinkRequest,
32
+ CreateNodeLinkResponse,
33
+ CreateCollectionResponse,
34
+ CreatePlacementRequest,
35
+ CreatePlacementResponse,
36
+ CreateRichNodeRequest,
37
+ CreateRichNodeResponse,
38
+ LinkRichNodeResponse,
39
+ MemoryResponse,
40
+ PlacementResponse,
41
+ )
42
+ from memuron.spaces.service import guardian_space_context, resolve_ingest_hint_space
43
+ from memuron.security.tenant import merge_org_scope
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ @actions.action(
49
+ name="node.create",
50
+ description="Create a typed rich memory node in the active organization.",
51
+ kind="write",
52
+ scopes=["memory:write"],
53
+ http=HttpExposure("POST", "/memuron/nodes"),
54
+ mcp=False,
55
+ cli="node create",
56
+ tags=["nodes"],
57
+ inject={"guardian": "guardian", "identity": "identity"},
58
+ )
59
+ async def node_create(
60
+ input: CreateRichNodeRequest,
61
+ engine: ArthaEngine,
62
+ context: ActionContext,
63
+ guardian: object,
64
+ identity: IdentityStore,
65
+ ) -> CreateRichNodeResponse:
66
+ meta = event_metadata(context)
67
+ scoped = merge_tenant_scope(input.scope, context)
68
+ space_context = None
69
+ candidate_scope = None
70
+ org_id = context.auth.tenant_id
71
+ user_id = str(context.auth.actor_id)
72
+ if org_id:
73
+ candidate_scope = merge_org_scope(None, str(org_id))
74
+ hint_space = resolve_ingest_hint_space(
75
+ identity,
76
+ user_id=user_id,
77
+ org_id=str(org_id),
78
+ space_id=None,
79
+ )
80
+ space_context = guardian_space_context(
81
+ identity,
82
+ user_id=user_id,
83
+ org_id=str(org_id),
84
+ hint_space=hint_space,
85
+ space_mode="assist",
86
+ )
87
+ requested_type = input.type or str(input.model_dump()["node_type"])
88
+ node = create_rich_node(
89
+ engine,
90
+ content=input.content,
91
+ node_type=requested_type,
92
+ payload=input.payload,
93
+ perception=input.perception,
94
+ encoding=input.encoding,
95
+ metadata=input.memory_metadata(),
96
+ scope=scoped,
97
+ event_metadata=meta,
98
+ )
99
+ if input.auto_link and requested_type not in {"collection"}:
100
+ link_text = (input.perception or input.content).strip()
101
+ if link_text and guardian is not None:
102
+ try:
103
+ linked = await link_memory_with_guardian(
104
+ engine,
105
+ guardian,
106
+ memory_id=str(node["id"]),
107
+ content=link_text,
108
+ scope=scoped,
109
+ event_metadata=meta,
110
+ space_context=space_context,
111
+ candidate_scope=candidate_scope,
112
+ )
113
+ node = get_memory(engine, str(node["id"]))
114
+ node["metadata"] = {
115
+ **(node.get("metadata") or {}),
116
+ "auto_link_count": linked,
117
+ }
118
+ except Exception as exc:
119
+ logger.warning("Rich node auto-link failed for %s: %s", node["id"], exc)
120
+ return CreateRichNodeResponse(node=MemoryResponse.model_validate(node))
121
+
122
+
123
+ @actions.action(
124
+ name="node.link",
125
+ description="Ask Guardian to create semantic links for a known memory in the active organization.",
126
+ kind="write",
127
+ scopes=["memory:write"],
128
+ http=HttpExposure("POST", "/memuron/nodes/{memory_id}/link"),
129
+ mcp=False,
130
+ cli="node link",
131
+ tags=["nodes"],
132
+ inject={"guardian": "guardian", "identity": "identity"},
133
+ )
134
+ async def node_link(
135
+ memory_id: str,
136
+ engine: ArthaEngine,
137
+ context: ActionContext,
138
+ guardian: object,
139
+ identity: IdentityStore,
140
+ ) -> LinkRichNodeResponse:
141
+ if guardian is None:
142
+ raise RuntimeError("Guardian not available")
143
+ meta = event_metadata(context)
144
+ memory = require_memory_in_tenant(engine, memory_id, context)
145
+ if memory.get("node_type") == "collection":
146
+ raise ValueError("collection nodes use placements, not semantic links")
147
+ scoped = list(memory.get("scope") or [])
148
+ scoped = merge_tenant_scope(scoped, context)
149
+ link_text = (memory.get("perception") or memory.get("content") or "").strip()
150
+ if not link_text:
151
+ raise ValueError("memory has no perception/content to link on")
152
+ space_context = None
153
+ org_id = context.auth.tenant_id
154
+ user_id = str(context.auth.actor_id)
155
+ if org_id:
156
+ hint_space = resolve_ingest_hint_space(
157
+ identity,
158
+ user_id=user_id,
159
+ org_id=str(org_id),
160
+ space_id=None,
161
+ )
162
+ space_context = guardian_space_context(
163
+ identity,
164
+ user_id=user_id,
165
+ org_id=str(org_id),
166
+ hint_space=hint_space,
167
+ space_mode="assist",
168
+ )
169
+ linked = await link_memory_with_guardian(
170
+ engine,
171
+ guardian,
172
+ memory_id=memory_id,
173
+ content=link_text,
174
+ scope=scoped,
175
+ event_metadata=meta,
176
+ space_context=space_context,
177
+ )
178
+ refreshed = get_memory(engine, memory_id)
179
+ refreshed["metadata"] = {
180
+ **(refreshed.get("metadata") or {}),
181
+ "auto_link_count": linked,
182
+ }
183
+ return LinkRichNodeResponse(
184
+ memory_id=memory_id,
185
+ links_created=linked,
186
+ node=MemoryResponse.model_validate(refreshed),
187
+ )
188
+
189
+
190
+ @actions.action(
191
+ name="node.create_link",
192
+ description="Create an explicit semantic link or self-loop alias for a known memory.",
193
+ kind="write",
194
+ scopes=["memory:write"],
195
+ http=HttpExposure("POST", "/memuron/nodes/{memory_id}/links"),
196
+ mcp=False,
197
+ cli="node create-link",
198
+ tags=["nodes"],
199
+ )
200
+ def node_create_link(
201
+ memory_id: str,
202
+ input: CreateNodeLinkRequest,
203
+ engine: ArthaEngine,
204
+ context: ActionContext,
205
+ ) -> CreateNodeLinkResponse:
206
+ meta = event_metadata(context)
207
+ target_id = input.target_id or memory_id
208
+ require_memory_in_tenant(engine, memory_id, context)
209
+ require_memory_in_tenant(engine, target_id, context)
210
+ link, created = create_memory_link(
211
+ engine,
212
+ source_id=memory_id,
213
+ target_id=target_id,
214
+ description=input.description,
215
+ event_metadata=meta,
216
+ )
217
+ refreshed = get_memory(engine, memory_id)
218
+ return CreateNodeLinkResponse(
219
+ memory_id=memory_id,
220
+ link=link,
221
+ created=created,
222
+ node=MemoryResponse.model_validate(refreshed),
223
+ )
224
+
225
+
226
+ @actions.action(
227
+ name="collection.create",
228
+ description="Create a named collection node in the active organization.",
229
+ kind="write",
230
+ scopes=["memory:write"],
231
+ http=HttpExposure("POST", "/memuron/collections"),
232
+ mcp=False,
233
+ cli="collection create",
234
+ tags=["collections"],
235
+ )
236
+ def collection_create(
237
+ input: CreateCollectionRequest,
238
+ engine: ArthaEngine,
239
+ context: ActionContext,
240
+ ) -> CreateCollectionResponse:
241
+ collection = create_collection(
242
+ engine,
243
+ name=input.name,
244
+ summary=input.summary,
245
+ scope=merge_tenant_scope(input.scope, context),
246
+ metadata=input.metadata,
247
+ event_metadata=event_metadata(context),
248
+ )
249
+ return CreateCollectionResponse(collection=MemoryResponse.model_validate(collection))
250
+
251
+
252
+ @actions.action(
253
+ name="collection.place",
254
+ description="Place a memory or collection inside a collection, optionally inheriting the parent scope.",
255
+ kind="write",
256
+ scopes=["memory:write"],
257
+ http=HttpExposure("POST", "/memuron/collections/{collection_id}/placements"),
258
+ mcp=False,
259
+ cli="collection place",
260
+ tags=["collections"],
261
+ )
262
+ def collection_place(
263
+ collection_id: str,
264
+ input: CreatePlacementRequest,
265
+ engine: ArthaEngine,
266
+ context: ActionContext,
267
+ ) -> CreatePlacementResponse:
268
+ require_memory_in_tenant(engine, collection_id, context)
269
+ require_memory_in_tenant(engine, input.child_id, context)
270
+ placement = place_node_in_collection(
271
+ engine,
272
+ parent_id=collection_id,
273
+ child_id=input.child_id,
274
+ name=input.name,
275
+ scope=merge_tenant_scope(input.scope, context),
276
+ metadata=input.metadata,
277
+ inherit_parent_scope=input.inherit_parent_scope,
278
+ event_metadata=event_metadata(context),
279
+ )
280
+ return CreatePlacementResponse(placement=PlacementResponse.model_validate(placement))
281
+
282
+
283
+ @actions.action(
284
+ name="collection.members",
285
+ description="List direct members of a known collection in the active organization.",
286
+ kind="read",
287
+ scopes=["memory:read"],
288
+ http=HttpExposure("GET", "/memuron/collections/{collection_id}/members"),
289
+ mcp=False,
290
+ cli="collection members",
291
+ tags=["collections"],
292
+ )
293
+ def collection_members_action(
294
+ collection_id: str,
295
+ engine: ArthaEngine,
296
+ context: ActionContext,
297
+ ) -> CollectionMembersResponse:
298
+ require_memory_in_tenant(engine, collection_id, context)
299
+ members = collection_members(engine, collection_id)
300
+ return CollectionMembersResponse(
301
+ collection_id=collection_id,
302
+ count=len(members),
303
+ members=members,
304
+ )
305
+
306
+
307
+ @actions.action(
308
+ name="collection.profile",
309
+ description=(
310
+ "Return a lightweight deterministic collection profile with direct member "
311
+ "summaries and a prompt-ready block."
312
+ ),
313
+ kind="read",
314
+ scopes=["memory:read"],
315
+ http=HttpExposure("GET", "/memuron/collections/{collection_id}/profile"),
316
+ mcp=False,
317
+ cli=False,
318
+ tags=["collections"],
319
+ )
320
+ def collection_profile_action(
321
+ collection_id: str,
322
+ engine: ArthaEngine,
323
+ context: ActionContext,
324
+ ) -> CollectionProfileResponse:
325
+ require_memory_in_tenant(engine, collection_id, context)
326
+ org_id = context.auth.tenant_id
327
+ if not org_id:
328
+ raise ValueError("Organization context required")
329
+ payload = collection_profile(
330
+ engine,
331
+ collection_id=collection_id,
332
+ org_id=str(org_id),
333
+ )
334
+ return CollectionProfileResponse.model_validate(
335
+ {
336
+ "collection_id": collection_id,
337
+ "profile": payload["profile"],
338
+ "prompt_text": payload["prompt_text"],
339
+ }
340
+ )
@@ -0,0 +1,5 @@
1
+ """Bound ActionRegistry for Memuron."""
2
+
3
+ from artha_engine import ActionRegistry
4
+
5
+ actions = ActionRegistry()
@@ -0,0 +1,37 @@
1
+ """Register Memuron runtime actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from artha_engine import ActionContext, HttpExposure
6
+
7
+ from memuron.actions.registry import actions
8
+ from memuron.application.capabilities import memuron_profile_manifest
9
+
10
+
11
+ @actions.action(
12
+ name="memuron.health",
13
+ kind="read",
14
+ public=True,
15
+ http=HttpExposure("GET", "/memuron/health"),
16
+ mcp=False,
17
+ cli=False,
18
+ tags=["memuron"],
19
+ )
20
+ def memuron_health() -> dict[str, str]:
21
+ return {"status": "ok", "service": "memuron"}
22
+
23
+
24
+ @actions.action(
25
+ name="memuron.profile",
26
+ kind="read",
27
+ scopes=["memory:read"],
28
+ http=HttpExposure("GET", "/memuron/profile"),
29
+ mcp=False,
30
+ cli=False,
31
+ tags=["memuron"],
32
+ )
33
+ def memuron_profile(context: ActionContext) -> dict[str, object]:
34
+ _ = context
35
+ from memuron.actions import actions
36
+
37
+ return memuron_profile_manifest({"actions": actions.manifests()})