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,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
|
+
)
|