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,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)
|
memuron/actions/nodes.py
ADDED
|
@@ -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,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()})
|