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,147 @@
1
+ """Guardian prompts and structured output schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ PROMPT_VERSION = "3.1"
10
+
11
+ SPACE_GUARDIAN_SECTION = """
12
+ SPACE CONTEXT
13
+ Active space: {active_space_name} ({active_space_token})
14
+ Space contract (what belongs here):
15
+ {active_space_prompt}
16
+
17
+ Other registered spaces (one-line summaries):
18
+ {other_spaces_summary}
19
+
20
+ Registered space tokens (pick from this list only):
21
+ {registered_tokens}
22
+
23
+ All registered spaces (name, token, contract):
24
+ {all_spaces_detail}
25
+
26
+ SPACE RULES — mode: {space_mode}
27
+ - Each memory belongs to exactly ONE `space.*` token unless the user content explicitly requires two (rare). Default: pick the single best-matching space.
28
+ - STRICT (user explicitly picked a space): MUST use `{active_space_token}` as the only space token unless the user text clearly spans two spaces.
29
+ - ASSIST (session default / no explicit pick): Choose the single best enabled `space.*` token. The hint space is a default only — route elsewhere when the contract fits better.
30
+ - Never tag all enabled spaces on one memory. Never invent tokens outside the registered list.
31
+ - Apply the active space contract for linking style when the memory belongs there.
32
+ """
33
+
34
+ MEMORY_WRITE_GUARDIAN_PROMPT = """You are the Guardian of the memory system.
35
+
36
+ Produce a COMPLETE write plan for one new memory input in a single pass.
37
+ Return ONLY valid JSON. No markdown. No extra commentary.
38
+
39
+ INPUT
40
+ New content:
41
+ {new_content}
42
+
43
+ Candidate memories (with semantic similarity scores):
44
+ {candidates_text}
45
+
46
+ If there are no candidates, treat this as an empty list.
47
+
48
+ DECISION RULES
49
+ - Choose `update` only if the new content is about the SAME specific memory and mainly expands, corrects, or refines it.
50
+ - Choose `create` if the new content is a distinct memory, even if it is related.
51
+ - NEVER choose `update` when the target candidate is an image, document, or collection node (`node_type` ≠ text). Link to those nodes instead; text ingest must not rewrite media/collection records.
52
+ - Prefer granular memories over monolithic ones.
53
+ - Similarity scores are hints only.
54
+
55
+ MEMORY SHAPING RULES
56
+ - `final_content` should be concise, coherent, natural, and information-dense.
57
+ - If `action` is `update`, `final_content` must already be the merged final memory text.
58
+ - If information conflicts and `update` is correct, prefer newer information while keeping the memory coherent.
59
+ - Extract unified `scope` (5-12 strings) from `final_content` for classification and filtering.
60
+
61
+ GRAPH ACTION RULES
62
+ - You may propose up to 2 meaningful graph links to candidate memories.
63
+ - Only link when there is clear causal, same-entity, dependency, continuation, or topic-deepening value.
64
+ - Do not link for shallow token overlap.
65
+ - Each link `description` must be 2-3 natural questions and should contain question marks.
66
+ - You may also propose stale links to remove using `links_to_remove`.
67
+ - Do not propose neighbor scope rewrites in this prompt.
68
+
69
+ JSON SCHEMA
70
+ {{
71
+ "action": "create" | "update",
72
+ "target_memory_id": "candidate id when action=update, otherwise null",
73
+ "reasoning": "brief explanation",
74
+ "final_content": "canonical memory text",
75
+ "scope": ["token1", "token2"],
76
+ "connections": [
77
+ {{"target_id": "candidate id", "description": "2-3 questions connecting the memories?"}}
78
+ ],
79
+ "links_to_remove": [["memory_id_1", "memory_id_2"]]
80
+ }}
81
+ """
82
+
83
+
84
+ class ConnectionSpec(BaseModel):
85
+ target_id: str
86
+ description: str
87
+
88
+
89
+ class GuardianWritePlan(BaseModel):
90
+ action: Literal["create", "update"]
91
+ target_memory_id: str | None = None
92
+ reasoning: str
93
+ final_content: str
94
+ scope: list[str] = Field(default_factory=list)
95
+ connections: list[ConnectionSpec] = Field(default_factory=list)
96
+ links_to_remove: list[list[str]] = Field(default_factory=list)
97
+
98
+
99
+ DOCUMENT_LINK_GUARDIAN_PROMPT = """You are the Guardian linking a newly ingested document into an existing memory graph.
100
+
101
+ You receive:
102
+ 1. EXTERNAL CANDIDATES — up to 30 existing memories (retrieved by embedding similarity across chunks).
103
+ 2. IN-DOCUMENT MEMORIES — chunks and image nodes from this ingest (chunks may link to each other or to images).
104
+ 3. Optional vision attachments for image nodes (when provided).
105
+
106
+ TASK
107
+ For EACH in-document chunk listed below, propose 0, 1, or 2 OUTGOING semantic links.
108
+ - Links may target EXTERNAL CANDIDATES or other IN-DOCUMENT memories (chunks or images).
109
+ - Do NOT link to the collection node or the document source node.
110
+ - Only link when there is clear topical, causal, dependency, continuation, or deepening value.
111
+ - Do not link for shallow overlap.
112
+ - Each link description must be 2-3 natural questions and must contain question marks.
113
+
114
+ Return ONLY valid JSON matching the schema. No markdown.
115
+
116
+ IN-DOCUMENT CHUNKS
117
+ {chunks_text}
118
+
119
+ IN-DOCUMENT IMAGES
120
+ {images_text}
121
+
122
+ EXTERNAL CANDIDATES
123
+ {external_text}
124
+
125
+ JSON SCHEMA
126
+ {{
127
+ "reasoning": "brief overall strategy",
128
+ "chunk_links": [
129
+ {{
130
+ "chunk_id": "chunk memory id",
131
+ "connections": [
132
+ {{"target_id": "allowed target id", "description": "2-3 questions?"}}
133
+ ]
134
+ }}
135
+ ]
136
+ }}
137
+ """
138
+
139
+
140
+ class ChunkLinkAssignment(BaseModel):
141
+ chunk_id: str
142
+ connections: list[ConnectionSpec] = Field(default_factory=list)
143
+
144
+
145
+ class DocumentLinkGuardianPlan(BaseModel):
146
+ reasoning: str = ""
147
+ chunk_links: list[ChunkLinkAssignment] = Field(default_factory=list)
@@ -0,0 +1 @@
1
+ """Memory event recipes, projections, and Artha engine adapter."""
@@ -0,0 +1,35 @@
1
+ """Memuron-specific ArthaEngine extensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse, urlunparse
6
+
7
+ from artha_engine import ArthaEngine
8
+ from artha_engine.store import is_postgres_dsn
9
+
10
+
11
+ def mask_database_url(url: str) -> str:
12
+ if not url:
13
+ return url
14
+ if is_postgres_dsn(url):
15
+ parsed = urlparse(url)
16
+ host = parsed.hostname or ""
17
+ if parsed.port:
18
+ host = f"{host}:{parsed.port}"
19
+ if parsed.username:
20
+ netloc = f"{parsed.username}:***@{host}"
21
+ else:
22
+ netloc = f"***@{host}" if host else "***"
23
+ return urlunparse(
24
+ (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)
25
+ )
26
+ return url
27
+
28
+
29
+ class MemuronArthaEngine(ArthaEngine):
30
+ def runtime_summary(self, *, profiles: list[str] | None = None) -> dict[str, object]:
31
+ summary = super().runtime_summary(profiles=profiles)
32
+ db_path = summary.get("db_path")
33
+ if db_path is not None:
34
+ summary = {**summary, "db_path": mask_database_url(str(db_path))}
35
+ return summary
@@ -0,0 +1,452 @@
1
+ """Memuron read-model projections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from artha_engine.store.projection_sql import sql_store_execute, sql_store_fetchall, sql_store_has_tables
9
+
10
+ from memuron.application.config import settings
11
+
12
+
13
+ def _json_dumps(value: object) -> str:
14
+ return json.dumps(value, ensure_ascii=True)
15
+
16
+
17
+ def _json_loads(value: object, default: object) -> object:
18
+ if value is None:
19
+ return default
20
+ if isinstance(value, (list, dict)):
21
+ return value
22
+ try:
23
+ return json.loads(str(value))
24
+ except json.JSONDecodeError:
25
+ return default
26
+
27
+
28
+ def _drop_legacy_category_column(store: Any) -> None:
29
+ if not sql_store_has_tables(store):
30
+ return
31
+ try:
32
+ sql_store_execute(store, "ALTER TABLE memuron_memories DROP COLUMN category")
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ def _add_column_if_missing(store: Any, table: str, column_sql: str) -> None:
38
+ if not sql_store_has_tables(store):
39
+ return
40
+ try:
41
+ sql_store_execute(store, f"ALTER TABLE {table} ADD COLUMN {column_sql}")
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ class MemoryProjection:
47
+ name = "memuron_memories"
48
+
49
+ def init(self, store: Any) -> None:
50
+ if not sql_store_has_tables(store):
51
+ store.memuron_memories = getattr(store, "memuron_memories", {})
52
+ return
53
+ sql_store_execute(
54
+ store,
55
+ """
56
+ CREATE TABLE IF NOT EXISTS memuron_memories (
57
+ artha_id TEXT PRIMARY KEY,
58
+ content TEXT NOT NULL,
59
+ node_type TEXT NOT NULL DEFAULT 'text',
60
+ payload_json TEXT NOT NULL DEFAULT '{}',
61
+ perception TEXT,
62
+ encoding TEXT NOT NULL DEFAULT 'memory',
63
+ metadata_json TEXT NOT NULL DEFAULT '{}',
64
+ scope_json TEXT NOT NULL DEFAULT '[]',
65
+ embedding_json TEXT NOT NULL DEFAULT '[]',
66
+ created_at TEXT,
67
+ updated_at TEXT,
68
+ sequence BIGINT NOT NULL DEFAULT 0
69
+ )
70
+ """,
71
+ )
72
+ _drop_legacy_category_column(store)
73
+ _add_column_if_missing(store, "memuron_memories", "node_type TEXT NOT NULL DEFAULT 'text'")
74
+ _add_column_if_missing(store, "memuron_memories", "payload_json TEXT NOT NULL DEFAULT '{}'")
75
+ _add_column_if_missing(store, "memuron_memories", "perception TEXT")
76
+ _add_column_if_missing(store, "memuron_memories", "encoding TEXT NOT NULL DEFAULT 'memory'")
77
+ _add_column_if_missing(store, "memuron_memories", "metadata_json TEXT NOT NULL DEFAULT '{}'")
78
+ from memuron.search.fulltext import ensure_fulltext_schema
79
+ from memuron.search.pgvector import ensure_pgvector_schema
80
+
81
+ ensure_pgvector_schema(store, settings.vector_dimensions)
82
+ ensure_fulltext_schema(store)
83
+
84
+ def apply(self, event: dict[str, object], store: Any) -> None:
85
+ event_type = str(event.get("event_type", ""))
86
+ if event_type in {"delete", "memory.deleted"}:
87
+ if event.get("subject_type") == "memory":
88
+ self._delete(str(event["subject_id"]), store)
89
+ return
90
+ if event_type not in {"memory.created", "memory.updated", "collection.created"}:
91
+ return
92
+ payload = event.get("payload")
93
+ if not isinstance(payload, dict):
94
+ return
95
+ arthaanu = payload.get("arthaanu")
96
+ if not isinstance(arthaanu, dict):
97
+ return
98
+ value = arthaanu.get("value")
99
+ if not isinstance(value, dict):
100
+ return
101
+ content = value.get("content")
102
+ if not isinstance(content, str):
103
+ return
104
+ scope = _json_loads(value.get("scope"), [])
105
+ embedding = _json_loads(value.get("embedding"), [])
106
+ node_type = str(value.get("node_type") or "text")
107
+ payload_json = _json_dumps(_json_loads(value.get("payload"), {}))
108
+ perception = value.get("perception")
109
+ encoding = str(value.get("encoding") or "memory")
110
+ metadata_json = _json_dumps(_json_loads(value.get("metadata"), {}))
111
+ artha_id = str(event["subject_id"])
112
+ sequence = int(event["sequence"])
113
+ metadata = event.get("metadata")
114
+ created_at = None
115
+ if isinstance(metadata, dict):
116
+ created_at = metadata.get("created_at")
117
+ if sql_store_has_tables(store):
118
+ existing = sql_store_fetchall(
119
+ store,
120
+ "SELECT created_at FROM memuron_memories WHERE artha_id = ?",
121
+ (artha_id,),
122
+ )
123
+ if existing and existing[0].get("created_at"):
124
+ created_at = existing[0]["created_at"]
125
+ sql_store_execute(
126
+ store,
127
+ """
128
+ INSERT INTO memuron_memories (
129
+ artha_id, content, node_type, payload_json, perception, encoding, metadata_json,
130
+ scope_json, embedding_json,
131
+ created_at, updated_at, sequence
132
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
+ ON CONFLICT(artha_id) DO UPDATE SET
134
+ content = excluded.content,
135
+ node_type = excluded.node_type,
136
+ payload_json = excluded.payload_json,
137
+ perception = excluded.perception,
138
+ encoding = excluded.encoding,
139
+ metadata_json = excluded.metadata_json,
140
+ scope_json = excluded.scope_json,
141
+ embedding_json = excluded.embedding_json,
142
+ updated_at = excluded.updated_at,
143
+ sequence = excluded.sequence
144
+ """,
145
+ (
146
+ artha_id,
147
+ content,
148
+ node_type,
149
+ payload_json,
150
+ perception if isinstance(perception, str) else None,
151
+ encoding,
152
+ metadata_json,
153
+ _json_dumps(scope if isinstance(scope, list) else []),
154
+ _json_dumps(embedding if isinstance(embedding, list) else []),
155
+ created_at or str(event.get("created_at", "")),
156
+ str(event.get("created_at", "")),
157
+ sequence,
158
+ ),
159
+ )
160
+ from memuron.search.fulltext import sync_memory_fulltext
161
+ from memuron.search.pgvector import sync_memory_embedding
162
+
163
+ sync_memory_embedding(
164
+ store,
165
+ artha_id,
166
+ [float(value) for value in embedding] if isinstance(embedding, list) else [],
167
+ )
168
+ sync_memory_fulltext(store, artha_id, content)
169
+ else:
170
+ bucket = store.memuron_memories
171
+ prior = bucket.get(artha_id, {})
172
+ bucket[artha_id] = {
173
+ "content": content,
174
+ "node_type": node_type,
175
+ "payload": _json_loads(value.get("payload"), {}),
176
+ "perception": perception if isinstance(perception, str) else None,
177
+ "encoding": encoding,
178
+ "metadata": _json_loads(value.get("metadata"), {}),
179
+ "scope": scope if isinstance(scope, list) else [],
180
+ "embedding": embedding if isinstance(embedding, list) else [],
181
+ "created_at": prior.get("created_at") or str(event.get("created_at", "")),
182
+ "updated_at": str(event.get("created_at", "")),
183
+ "sequence": sequence,
184
+ }
185
+
186
+ def reset(self, store: Any) -> None:
187
+ if sql_store_has_tables(store):
188
+ sql_store_execute(store, "DELETE FROM memuron_memories")
189
+ else:
190
+ store.memuron_memories = {}
191
+
192
+ def _delete(self, artha_id: str, store: Any) -> None:
193
+ if sql_store_has_tables(store):
194
+ sql_store_execute(
195
+ store,
196
+ "DELETE FROM memuron_memories WHERE artha_id = ?",
197
+ (artha_id,),
198
+ )
199
+ else:
200
+ store.memuron_memories.pop(artha_id, None)
201
+
202
+
203
+ class MemoryLinkProjection:
204
+ name = "memuron_links"
205
+
206
+ def init(self, store: Any) -> None:
207
+ if not sql_store_has_tables(store):
208
+ store.memuron_links = getattr(store, "memuron_links", {})
209
+ return
210
+ sql_store_execute(
211
+ store,
212
+ """
213
+ CREATE TABLE IF NOT EXISTS memuron_links (
214
+ link_id TEXT PRIMARY KEY,
215
+ source_id TEXT NOT NULL,
216
+ target_id TEXT NOT NULL,
217
+ description TEXT NOT NULL,
218
+ metadata_json TEXT NOT NULL DEFAULT '{}',
219
+ embedding_json TEXT NOT NULL DEFAULT '[]',
220
+ sequence BIGINT NOT NULL DEFAULT 0
221
+ )
222
+ """,
223
+ )
224
+ _add_column_if_missing(store, "memuron_links", "metadata_json TEXT NOT NULL DEFAULT '{}'")
225
+ sql_store_execute(
226
+ store,
227
+ "CREATE INDEX IF NOT EXISTS idx_memuron_links_source ON memuron_links(source_id)",
228
+ )
229
+ sql_store_execute(
230
+ store,
231
+ "CREATE INDEX IF NOT EXISTS idx_memuron_links_target ON memuron_links(target_id)",
232
+ )
233
+
234
+ def apply(self, event: dict[str, object], store: Any) -> None:
235
+ event_type = str(event.get("event_type", ""))
236
+ if event_type == "delete" and event.get("subject_type") == "memory_link":
237
+ # Legacy replay only — new writes use link.removed.
238
+ self._delete(str(event["subject_id"]), store)
239
+ return
240
+ if event_type not in {"link.created", "link.removed"}:
241
+ return
242
+ if event_type == "link.removed":
243
+ link_id = str(event.get("subject_id", ""))
244
+ payload = event.get("payload")
245
+ if isinstance(payload, dict) and payload.get("link_id"):
246
+ link_id = str(payload["link_id"])
247
+ if link_id:
248
+ self._delete(link_id, store)
249
+ return
250
+ payload = event.get("payload")
251
+ if not isinstance(payload, dict):
252
+ return
253
+ arthaanu = payload.get("arthaanu")
254
+ if not isinstance(arthaanu, dict):
255
+ return
256
+ value = arthaanu.get("value")
257
+ if not isinstance(value, dict):
258
+ return
259
+ source_id = value.get("source_id")
260
+ target_id = value.get("target_id")
261
+ description = value.get("description")
262
+ if not isinstance(source_id, str) or not isinstance(target_id, str):
263
+ return
264
+ if not isinstance(description, str):
265
+ return
266
+ metadata = _json_loads(value.get("metadata"), {})
267
+ embedding = _json_loads(value.get("embedding"), [])
268
+ link_id = str(event["subject_id"])
269
+ sequence = int(event["sequence"])
270
+ if sql_store_has_tables(store):
271
+ sql_store_execute(
272
+ store,
273
+ """
274
+ INSERT INTO memuron_links (
275
+ link_id, source_id, target_id, description, metadata_json, embedding_json, sequence
276
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
277
+ ON CONFLICT(link_id) DO UPDATE SET
278
+ source_id = excluded.source_id,
279
+ target_id = excluded.target_id,
280
+ description = excluded.description,
281
+ metadata_json = excluded.metadata_json,
282
+ embedding_json = excluded.embedding_json,
283
+ sequence = excluded.sequence
284
+ """,
285
+ (
286
+ link_id,
287
+ source_id,
288
+ target_id,
289
+ description,
290
+ _json_dumps(metadata if isinstance(metadata, dict) else {}),
291
+ _json_dumps(embedding if isinstance(embedding, list) else []),
292
+ sequence,
293
+ ),
294
+ )
295
+ from memuron.search.pgvector import sync_link_embedding
296
+
297
+ sync_link_embedding(
298
+ store,
299
+ link_id,
300
+ [float(value) for value in embedding] if isinstance(embedding, list) else [],
301
+ )
302
+ else:
303
+ store.memuron_links[link_id] = {
304
+ "source_id": source_id,
305
+ "target_id": target_id,
306
+ "description": description,
307
+ "metadata": metadata if isinstance(metadata, dict) else {},
308
+ "embedding": embedding if isinstance(embedding, list) else [],
309
+ "sequence": sequence,
310
+ }
311
+
312
+ def reset(self, store: Any) -> None:
313
+ if sql_store_has_tables(store):
314
+ sql_store_execute(store, "DELETE FROM memuron_links")
315
+ else:
316
+ store.memuron_links = {}
317
+
318
+ def _delete(self, link_id: str, store: Any) -> None:
319
+ if sql_store_has_tables(store):
320
+ sql_store_execute(
321
+ store,
322
+ "DELETE FROM memuron_links WHERE link_id = ?",
323
+ (link_id,),
324
+ )
325
+ else:
326
+ store.memuron_links.pop(link_id, None)
327
+
328
+
329
+ class MemoryPlacementProjection:
330
+ name = "memuron_placements"
331
+
332
+ def init(self, store: Any) -> None:
333
+ if not sql_store_has_tables(store):
334
+ store.memuron_placements = getattr(store, "memuron_placements", {})
335
+ return
336
+ sql_store_execute(
337
+ store,
338
+ """
339
+ CREATE TABLE IF NOT EXISTS memuron_placements (
340
+ placement_id TEXT PRIMARY KEY,
341
+ parent_id TEXT NOT NULL,
342
+ child_id TEXT NOT NULL,
343
+ name TEXT NOT NULL,
344
+ scope_json TEXT NOT NULL DEFAULT '[]',
345
+ metadata_json TEXT NOT NULL DEFAULT '{}',
346
+ inherit_parent_scope BOOLEAN NOT NULL DEFAULT TRUE,
347
+ sequence BIGINT NOT NULL DEFAULT 0,
348
+ UNIQUE(parent_id, name)
349
+ )
350
+ """,
351
+ )
352
+ _add_column_if_missing(
353
+ store,
354
+ "memuron_placements",
355
+ "inherit_parent_scope BOOLEAN NOT NULL DEFAULT TRUE",
356
+ )
357
+ sql_store_execute(
358
+ store,
359
+ "CREATE INDEX IF NOT EXISTS idx_memuron_placements_parent ON memuron_placements(parent_id)",
360
+ )
361
+ sql_store_execute(
362
+ store,
363
+ "CREATE INDEX IF NOT EXISTS idx_memuron_placements_child ON memuron_placements(child_id)",
364
+ )
365
+
366
+ def apply(self, event: dict[str, object], store: Any) -> None:
367
+ event_type = str(event.get("event_type", ""))
368
+ if event_type == "delete" and event.get("subject_type") == "memory_placement":
369
+ self._delete(str(event["subject_id"]), store)
370
+ return
371
+ if event_type not in {"placement.created", "placement.removed"}:
372
+ return
373
+ if event_type == "placement.removed":
374
+ placement_id = str(event.get("subject_id", ""))
375
+ payload = event.get("payload")
376
+ if isinstance(payload, dict) and payload.get("placement_id"):
377
+ placement_id = str(payload["placement_id"])
378
+ if placement_id:
379
+ self._delete(placement_id, store)
380
+ return
381
+ payload = event.get("payload")
382
+ if not isinstance(payload, dict):
383
+ return
384
+ arthaanu = payload.get("arthaanu")
385
+ if not isinstance(arthaanu, dict):
386
+ return
387
+ value = arthaanu.get("value")
388
+ if not isinstance(value, dict):
389
+ return
390
+ parent_id = value.get("parent_id")
391
+ child_id = value.get("child_id")
392
+ name = value.get("name")
393
+ if not isinstance(parent_id, str) or not isinstance(child_id, str) or not isinstance(name, str):
394
+ return
395
+ scope = _json_loads(value.get("scope"), [])
396
+ metadata = _json_loads(value.get("metadata"), {})
397
+ inherit_parent_scope = bool(value.get("inherit_parent_scope", True))
398
+ placement_id = str(event["subject_id"])
399
+ sequence = int(event["sequence"])
400
+ if sql_store_has_tables(store):
401
+ sql_store_execute(
402
+ store,
403
+ """
404
+ INSERT INTO memuron_placements (
405
+ placement_id, parent_id, child_id, name, scope_json, metadata_json,
406
+ inherit_parent_scope, sequence
407
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
408
+ ON CONFLICT(parent_id, name) DO UPDATE SET
409
+ placement_id = excluded.placement_id,
410
+ child_id = excluded.child_id,
411
+ scope_json = excluded.scope_json,
412
+ metadata_json = excluded.metadata_json,
413
+ inherit_parent_scope = excluded.inherit_parent_scope,
414
+ sequence = excluded.sequence
415
+ """,
416
+ (
417
+ placement_id,
418
+ parent_id,
419
+ child_id,
420
+ name,
421
+ _json_dumps(scope if isinstance(scope, list) else []),
422
+ _json_dumps(metadata if isinstance(metadata, dict) else {}),
423
+ inherit_parent_scope,
424
+ sequence,
425
+ ),
426
+ )
427
+ else:
428
+ store.memuron_placements[placement_id] = {
429
+ "parent_id": parent_id,
430
+ "child_id": child_id,
431
+ "name": name,
432
+ "scope": scope if isinstance(scope, list) else [],
433
+ "metadata": metadata if isinstance(metadata, dict) else {},
434
+ "inherit_parent_scope": inherit_parent_scope,
435
+ "sequence": sequence,
436
+ }
437
+
438
+ def reset(self, store: Any) -> None:
439
+ if sql_store_has_tables(store):
440
+ sql_store_execute(store, "DELETE FROM memuron_placements")
441
+ else:
442
+ store.memuron_placements = {}
443
+
444
+ def _delete(self, placement_id: str, store: Any) -> None:
445
+ if sql_store_has_tables(store):
446
+ sql_store_execute(
447
+ store,
448
+ "DELETE FROM memuron_placements WHERE placement_id = ?",
449
+ (placement_id,),
450
+ )
451
+ else:
452
+ store.memuron_placements.pop(placement_id, None)