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
memuron/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from memuron.application.registry import build_registry
2
+
3
+ __all__ = ["build_registry"]
@@ -0,0 +1,12 @@
1
+ """Import all action modules to register handlers on the shared registry."""
2
+
3
+ from memuron.actions.registry import actions
4
+ from memuron.actions import context as _context # noqa: F401
5
+ from memuron.actions import memory as _memory # noqa: F401
6
+ from memuron.actions import memory_write as _memory_write # noqa: F401
7
+ from memuron.actions import nodes as _nodes # noqa: F401
8
+ from memuron.actions import runtime as _runtime # noqa: F401
9
+ from memuron.actions import spaces_documents as _spaces_documents # noqa: F401
10
+ from memuron.actions import sync as _sync # noqa: F401
11
+
12
+ __all__ = ["actions"]
@@ -0,0 +1,63 @@
1
+ """Context assembly actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from artha_engine import ActionContext, ArthaEngine, HttpExposure
6
+
7
+ from memuron.actions.helpers import merge_tenant_scope, require_user_org
8
+ from memuron.actions.registry import actions
9
+ from memuron.context import assemble_context
10
+ from memuron.domain.schemas import AssembleContextRequest, AssembleContextResponse
11
+ from memuron.persistence.identity_store import IdentityStore
12
+ from memuron.spaces.service import resolve_space_reference
13
+
14
+
15
+ @actions.action(
16
+ name="context.assemble",
17
+ description=(
18
+ "Assemble graph-native prompt context from memory search results, "
19
+ "collection breadcrumbs, and semantic links with citation metadata."
20
+ ),
21
+ kind="read",
22
+ scopes=["memory:read"],
23
+ http=HttpExposure("POST", "/memuron/context/assemble"),
24
+ mcp=False,
25
+ cli=False,
26
+ inject={"identity": "identity"},
27
+ tags=["context"],
28
+ )
29
+ def context_assemble(
30
+ input: AssembleContextRequest,
31
+ engine: ArthaEngine,
32
+ context: ActionContext,
33
+ identity: IdentityStore,
34
+ ) -> AssembleContextResponse:
35
+ _user_id, org_id = require_user_org(context.auth)
36
+ scoped = merge_tenant_scope(input.scope, context)
37
+ preferred_space_token = None
38
+ if input.space_ref:
39
+ space = resolve_space_reference(
40
+ identity,
41
+ org_id=org_id,
42
+ space_ref=input.space_ref,
43
+ )
44
+ if space is None:
45
+ raise KeyError("Space not found")
46
+ preferred_space_token = str(space["token"])
47
+ scoped = [token for token in scoped if not token.startswith("space.")]
48
+ if preferred_space_token not in scoped:
49
+ scoped.append(preferred_space_token)
50
+
51
+ payload = assemble_context(
52
+ engine,
53
+ query=input.query,
54
+ k=input.k,
55
+ scope=scoped or None,
56
+ org_id=org_id,
57
+ preferred_space_token=preferred_space_token,
58
+ token_budget=input.token_budget,
59
+ char_budget=input.char_budget,
60
+ include_links=input.include_links,
61
+ include_breadcrumbs=input.include_breadcrumbs,
62
+ )
63
+ return AssembleContextResponse.model_validate(payload)
@@ -0,0 +1,88 @@
1
+ """Shared helpers for Memuron Artha actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from artha_engine.app.context import ActionContext
9
+ from artha_engine.runtime.auth import AuthContext
10
+
11
+ from memuron.domain.limits import MAX_SCOPE_ITEMS, MAX_SCOPE_TOKEN_LEN
12
+ from memuron.security.tenant import merge_org_scope
13
+
14
+
15
+ def require_user_org(auth: AuthContext) -> tuple[str, str]:
16
+ org_id = auth.tenant_id
17
+ if not org_id:
18
+ raise ValueError("Organization context required")
19
+ return str(auth.actor_id), str(org_id)
20
+
21
+
22
+ def require_memory_in_tenant(
23
+ engine: Any,
24
+ memory_id: str,
25
+ context: ActionContext,
26
+ ) -> dict[str, Any]:
27
+ """Return a memory only when it belongs to the caller's active organization."""
28
+ from memuron.memory.recipes import get_memory
29
+ from memuron.security.tenant import org_scope_token
30
+
31
+ _user_id, org_id = require_user_org(context.auth)
32
+ memory = get_memory(engine, memory_id)
33
+ if org_scope_token(org_id) not in set(memory.get("scope") or []):
34
+ raise KeyError(f"Memory not found: {memory_id}")
35
+ return memory
36
+
37
+
38
+ def event_metadata(
39
+ context: ActionContext,
40
+ *,
41
+ space_context: dict[str, str] | None = None,
42
+ ) -> dict[str, object]:
43
+ metadata = context.auth.event_metadata()
44
+ if context.request_id:
45
+ metadata["request_id"] = context.request_id
46
+ if space_context:
47
+ metadata["space_context"] = space_context
48
+ return metadata
49
+
50
+
51
+ def merge_tenant_scope(scope: list[str] | None, context: ActionContext) -> list[str]:
52
+ return merge_org_scope(scope, context.auth.tenant_id)
53
+
54
+
55
+ def tenant_scope_query(context: ActionContext, scope: str | None) -> str | None:
56
+ from memuron.security.tenant import tenant_scope_query as _tenant_scope_query
57
+
58
+ return _tenant_scope_query(context.auth.tenant_id, scope)
59
+
60
+
61
+ def parse_scope_form(value: str | None) -> list[str] | None:
62
+ if value is None or not value.strip():
63
+ return None
64
+ raw = value.strip()
65
+ if raw.startswith("["):
66
+ decoded = json.loads(raw)
67
+ if not isinstance(decoded, list) or not all(isinstance(item, str) for item in decoded):
68
+ raise ValueError("scope must be a list of strings")
69
+ scope = decoded
70
+ else:
71
+ scope = [part.strip() for part in raw.split(",") if part.strip()]
72
+ if len(scope) > MAX_SCOPE_ITEMS:
73
+ raise ValueError(f"scope can contain at most {MAX_SCOPE_ITEMS} tokens")
74
+ for token in scope:
75
+ if len(token) > MAX_SCOPE_TOKEN_LEN:
76
+ raise ValueError(
77
+ f"Each scope token must be at most {MAX_SCOPE_TOKEN_LEN} characters"
78
+ )
79
+ return scope
80
+
81
+
82
+ def parse_metadata_form(value: str | None) -> dict[str, object]:
83
+ if value is None or not value.strip():
84
+ return {}
85
+ decoded = json.loads(value)
86
+ if not isinstance(decoded, dict):
87
+ raise ValueError("metadata must be a JSON object")
88
+ return decoded
@@ -0,0 +1,340 @@
1
+ """Memory read/search actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from artha_engine import ActionContext, ArthaEngine, HttpExposure
6
+
7
+ from memuron.actions.helpers import (
8
+ merge_tenant_scope,
9
+ require_memory_in_tenant,
10
+ tenant_scope_query,
11
+ )
12
+ from memuron.actions.registry import actions
13
+ from memuron.memory.recipes import (
14
+ count_memories,
15
+ export_graph,
16
+ get_memory,
17
+ graph_hubs,
18
+ graph_neighborhood,
19
+ graph_path,
20
+ list_memories,
21
+ search_memories,
22
+ semantic_traverse_graph,
23
+ )
24
+ from memuron.domain.schemas import (
25
+ CountMemoriesResponse,
26
+ GetMemoriesRequest,
27
+ GetMemoriesResponse,
28
+ GetMemoryResponse,
29
+ GetMemoryViewResponse,
30
+ GraphExportResponse,
31
+ GraphHubsResponse,
32
+ GraphNeighborhoodResponse,
33
+ GraphPathResponse,
34
+ GraphTraversalResponse,
35
+ ListMemoriesResponse,
36
+ MemoryResponse,
37
+ SearchMemoriesRequest,
38
+ SearchResponse,
39
+ )
40
+ from memuron.domain.scope_filter import parse_comma_scope
41
+
42
+
43
+ @actions.action(
44
+ name="memory.count",
45
+ description="Count memories in the active organization, optionally filtered by scope.",
46
+ kind="read",
47
+ scopes=["memory:read"],
48
+ http=HttpExposure("GET", "/memuron/memories/count"),
49
+ mcp=False,
50
+ cli="memory count",
51
+ tags=["memory"],
52
+ )
53
+ def memory_count(
54
+ engine: ArthaEngine,
55
+ context: ActionContext,
56
+ scope: str | None = None,
57
+ ) -> CountMemoriesResponse:
58
+ effective_scope = tenant_scope_query(context, scope)
59
+ if effective_scope is not None:
60
+ parse_comma_scope(effective_scope)
61
+ count, filters = count_memories(engine, scope=effective_scope)
62
+ return CountMemoriesResponse(count=count, filters=filters)
63
+
64
+
65
+ @actions.action(
66
+ name="memory.list",
67
+ description="List memories in the active organization with pagination and optional scope filtering.",
68
+ kind="read",
69
+ scopes=["memory:read"],
70
+ http=HttpExposure("GET", "/memuron/memories"),
71
+ mcp=False,
72
+ cli="memory list",
73
+ tags=["memory"],
74
+ )
75
+ def memory_list(
76
+ engine: ArthaEngine,
77
+ context: ActionContext,
78
+ scope: str | None = None,
79
+ limit: int = 100,
80
+ offset: int = 0,
81
+ ) -> ListMemoriesResponse:
82
+ if limit < 1 or limit > 1000:
83
+ raise ValueError("limit must be between 1 and 1000")
84
+ if offset < 0:
85
+ raise ValueError("offset must be >= 0")
86
+ effective_scope = tenant_scope_query(context, scope)
87
+ if effective_scope is not None:
88
+ parse_comma_scope(effective_scope)
89
+ memories, total, filters = list_memories(
90
+ engine,
91
+ scope=effective_scope,
92
+ limit=limit,
93
+ offset=offset,
94
+ )
95
+ return ListMemoriesResponse(
96
+ count=total,
97
+ memories=[MemoryResponse.model_validate(item) for item in memories],
98
+ filters=filters,
99
+ )
100
+
101
+
102
+ @actions.action(
103
+ name="memory.search",
104
+ description="Run direct semantic memory search. Agents should normally prefer memuron_query, which combines semantic, regex, and graph operations.",
105
+ kind="read",
106
+ scopes=["memory:read"],
107
+ http=HttpExposure("POST", "/memuron/memories/search"),
108
+ mcp=False,
109
+ cli="memory search",
110
+ tags=["memory"],
111
+ )
112
+ def memory_search(
113
+ input: SearchMemoriesRequest,
114
+ engine: ArthaEngine,
115
+ context: ActionContext,
116
+ ) -> SearchResponse:
117
+ scoped = merge_tenant_scope(input.scope, context)
118
+ results, scope = search_memories(engine, input.query, k=input.k, scope=scoped or None)
119
+ return SearchResponse(count=len(results), scope=scope, results=results)
120
+
121
+
122
+ @actions.action(
123
+ name="memory.get",
124
+ description="Fetch one memory by ID from the active organization after an ID is discovered through memuron_query.",
125
+ kind="read",
126
+ scopes=["memory:read"],
127
+ http=HttpExposure("GET", "/memuron/memories/{memory_id}"),
128
+ mcp=False,
129
+ cli="memory get",
130
+ tags=["memory"],
131
+ )
132
+ def memory_get(
133
+ engine: ArthaEngine,
134
+ context: ActionContext,
135
+ memory_id: str,
136
+ ) -> GetMemoryResponse:
137
+ memory = require_memory_in_tenant(engine, memory_id, context)
138
+ return GetMemoryResponse(memory=MemoryResponse.model_validate(memory))
139
+
140
+
141
+ @actions.action(
142
+ name="memory.get_mcp",
143
+ description="Fetch a known memory with bounded content. Use fields to select keys and raise max_chars only when the full body is necessary.",
144
+ kind="read",
145
+ scopes=["memory:read"],
146
+ mcp="memuron_get",
147
+ cli=False,
148
+ tags=["memory"],
149
+ )
150
+ def memory_get_mcp(
151
+ engine: ArthaEngine,
152
+ context: ActionContext,
153
+ memory_id: str,
154
+ fields: list[str] | None = None,
155
+ max_chars: int = 4000,
156
+ include_perception: bool = False,
157
+ ) -> GetMemoryViewResponse:
158
+ if max_chars < 0 or max_chars > 100_000:
159
+ raise ValueError("max_chars must be between 0 and 100000")
160
+ memory = require_memory_in_tenant(engine, memory_id, context)
161
+ allowed = set(MemoryResponse.model_fields)
162
+ selected = set(fields or allowed)
163
+ unknown = selected - allowed
164
+ if unknown:
165
+ raise ValueError(f"Unknown fields: {', '.join(sorted(unknown))}")
166
+ if not include_perception:
167
+ selected.discard("perception")
168
+ view = {key: value for key, value in memory.items() if key in selected}
169
+ truncated_fields: list[str] = []
170
+ for key in ("content", "perception"):
171
+ value = view.get(key)
172
+ if isinstance(value, str) and len(value) > max_chars:
173
+ view[key] = value[:max_chars]
174
+ truncated_fields.append(key)
175
+ return GetMemoryViewResponse(
176
+ memory=view,
177
+ truncated_fields=truncated_fields,
178
+ )
179
+
180
+
181
+ @actions.action(
182
+ name="memory.batch_get",
183
+ description="Fetch several known memory IDs from the active organization.",
184
+ kind="read",
185
+ scopes=["memory:read"],
186
+ http=HttpExposure("POST", "/memuron/memories/batch"),
187
+ mcp=False,
188
+ cli="memory batch-get",
189
+ tags=["memory"],
190
+ )
191
+ def memory_batch_get(
192
+ engine: ArthaEngine,
193
+ context: ActionContext,
194
+ input: GetMemoriesRequest,
195
+ ) -> GetMemoriesResponse:
196
+ memories = [
197
+ require_memory_in_tenant(engine, memory_id, context)
198
+ for memory_id in input.memory_ids
199
+ ]
200
+ return GetMemoriesResponse(
201
+ count=len(memories),
202
+ memories=[MemoryResponse.model_validate(item) for item in memories],
203
+ )
204
+
205
+
206
+ @actions.action(
207
+ name="graph.export",
208
+ description="Export a bounded graph snapshot for the active organization. Prefer memuron_query for ordinary graph navigation.",
209
+ kind="read",
210
+ scopes=["memory:read"],
211
+ http=HttpExposure("GET", "/memuron/graph/export"),
212
+ mcp=False,
213
+ cli="graph export",
214
+ tags=["graph"],
215
+ )
216
+ def graph_export(
217
+ engine: ArthaEngine,
218
+ context: ActionContext,
219
+ scope: str | None = None,
220
+ limit: int = 1000,
221
+ ) -> GraphExportResponse:
222
+ effective_scope = tenant_scope_query(context, scope)
223
+ if effective_scope is not None:
224
+ parse_comma_scope(effective_scope)
225
+ return GraphExportResponse(
226
+ graph=export_graph(engine, scope=effective_scope, limit=limit)
227
+ )
228
+
229
+
230
+ @actions.action(
231
+ name="graph.hubs",
232
+ description="Find highly connected memories in the active organization.",
233
+ kind="read",
234
+ scopes=["memory:read"],
235
+ http=HttpExposure("GET", "/memuron/graph/hubs"),
236
+ mcp=False,
237
+ cli="graph hubs",
238
+ tags=["graph"],
239
+ )
240
+ def graph_hubs_action(
241
+ engine: ArthaEngine,
242
+ context: ActionContext,
243
+ scope: str | None = None,
244
+ limit: int = 10,
245
+ ) -> GraphHubsResponse:
246
+ effective_scope = tenant_scope_query(context, scope)
247
+ if effective_scope is not None:
248
+ parse_comma_scope(effective_scope)
249
+ hubs, total_memories = graph_hubs(engine, scope=effective_scope, limit=limit)
250
+ return GraphHubsResponse(hubs=hubs, total_memories=total_memories)
251
+
252
+
253
+ @actions.action(
254
+ name="graph.neighborhood",
255
+ description="Inspect the bounded graph neighborhood around a known memory in the active organization.",
256
+ kind="read",
257
+ scopes=["memory:read"],
258
+ http=HttpExposure("GET", "/memuron/graph/neighborhood"),
259
+ mcp=False,
260
+ cli="graph neighborhood",
261
+ tags=["graph"],
262
+ )
263
+ def graph_neighborhood_action(
264
+ engine: ArthaEngine,
265
+ context: ActionContext,
266
+ memory_id: str,
267
+ hops: int = 2,
268
+ scope: str | None = None,
269
+ ) -> GraphNeighborhoodResponse:
270
+ require_memory_in_tenant(engine, memory_id, context)
271
+ effective_scope = tenant_scope_query(context, scope)
272
+ if effective_scope is not None:
273
+ parse_comma_scope(effective_scope)
274
+ payload = graph_neighborhood(
275
+ engine,
276
+ memory_id=memory_id,
277
+ hops=hops,
278
+ scope=effective_scope,
279
+ )
280
+ return GraphNeighborhoodResponse(**payload)
281
+
282
+
283
+ @actions.action(
284
+ name="graph.path",
285
+ description="Find a graph path between two known memories in the active organization.",
286
+ kind="read",
287
+ scopes=["memory:read"],
288
+ http=HttpExposure("GET", "/memuron/graph/path"),
289
+ mcp=False,
290
+ cli="graph path",
291
+ tags=["graph"],
292
+ )
293
+ def graph_path_action(
294
+ engine: ArthaEngine,
295
+ context: ActionContext,
296
+ from_id: str,
297
+ to_id: str,
298
+ scope: str | None = None,
299
+ ) -> GraphPathResponse:
300
+ require_memory_in_tenant(engine, from_id, context)
301
+ require_memory_in_tenant(engine, to_id, context)
302
+ effective_scope = tenant_scope_query(context, scope)
303
+ if effective_scope is not None:
304
+ parse_comma_scope(effective_scope)
305
+ payload = graph_path(engine, from_id=from_id, to_id=to_id, scope=effective_scope)
306
+ return GraphPathResponse(**payload)
307
+
308
+
309
+ @actions.action(
310
+ name="graph.traverse",
311
+ description="Semantically traverse graph edges from a known memory in the active organization.",
312
+ kind="read",
313
+ scopes=["memory:read"],
314
+ http=HttpExposure("GET", "/memuron/graph/traverse"),
315
+ mcp=False,
316
+ cli="graph traverse",
317
+ tags=["graph"],
318
+ )
319
+ def graph_traverse_action(
320
+ engine: ArthaEngine,
321
+ context: ActionContext,
322
+ start_memory_id: str,
323
+ query: str,
324
+ max_hops: int = 2,
325
+ edge_similarity_threshold: float = 0.7,
326
+ scope: str | None = None,
327
+ ) -> GraphTraversalResponse:
328
+ require_memory_in_tenant(engine, start_memory_id, context)
329
+ effective_scope = tenant_scope_query(context, scope)
330
+ if effective_scope is not None:
331
+ parse_comma_scope(effective_scope)
332
+ payload = semantic_traverse_graph(
333
+ engine,
334
+ start_memory_id=start_memory_id,
335
+ query=query,
336
+ max_hops=max_hops,
337
+ edge_similarity_threshold=edge_similarity_threshold,
338
+ scope=effective_scope,
339
+ )
340
+ return GraphTraversalResponse(**payload)