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,918 @@
1
+ """Users, organizations, and memberships for Memuron auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ import threading
9
+ from contextlib import contextmanager
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from time import monotonic
13
+ from typing import Any, Iterator
14
+ from uuid import uuid4
15
+
16
+ from memuron.spaces.model import compile_space_token, default_personal_space, slugify_space
17
+
18
+ import psycopg
19
+ from artha_engine.store import is_postgres_dsn
20
+ from psycopg.rows import dict_row
21
+ from psycopg_pool import ConnectionPool
22
+
23
+
24
+ def _utcnow() -> datetime:
25
+ return datetime.now(UTC).replace(tzinfo=None)
26
+
27
+
28
+ def _slugify(value: str) -> str:
29
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
30
+ return slug or "org"
31
+
32
+
33
+ class IdentityStore:
34
+ def __init__(self, database_target: str | Path) -> None:
35
+ self.database_target = str(database_target)
36
+ self.is_postgres = is_postgres_dsn(self.database_target)
37
+ self._space_cache: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {}
38
+ self._space_cache_lock = threading.RLock()
39
+ self._pool: ConnectionPool | None = None
40
+ if self.is_postgres:
41
+ self._pool = ConnectionPool(
42
+ conninfo=self.database_target,
43
+ min_size=1,
44
+ max_size=8,
45
+ kwargs={"row_factory": dict_row},
46
+ open=True,
47
+ )
48
+ self._init_schema()
49
+
50
+ @classmethod
51
+ def from_target(cls, database_target: str | Path) -> "IdentityStore":
52
+ return cls(database_target)
53
+
54
+ def _init_schema(self) -> None:
55
+ if self.is_postgres:
56
+ with self._connect_postgres() as conn:
57
+ conn.execute(
58
+ """
59
+ CREATE TABLE IF NOT EXISTS memuron_users (
60
+ id UUID PRIMARY KEY,
61
+ email TEXT NOT NULL UNIQUE,
62
+ password_hash TEXT NOT NULL,
63
+ name TEXT NOT NULL,
64
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
65
+ )
66
+ """
67
+ )
68
+ conn.execute(
69
+ """
70
+ CREATE TABLE IF NOT EXISTS memuron_orgs (
71
+ id UUID PRIMARY KEY,
72
+ slug TEXT NOT NULL UNIQUE,
73
+ name TEXT NOT NULL,
74
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
75
+ )
76
+ """
77
+ )
78
+ conn.execute(
79
+ """
80
+ CREATE TABLE IF NOT EXISTS memuron_org_memberships (
81
+ org_id UUID NOT NULL REFERENCES memuron_orgs(id) ON DELETE CASCADE,
82
+ user_id UUID NOT NULL REFERENCES memuron_users(id) ON DELETE CASCADE,
83
+ role TEXT NOT NULL DEFAULT 'member',
84
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
85
+ PRIMARY KEY (org_id, user_id)
86
+ )
87
+ """
88
+ )
89
+ conn.execute(
90
+ """
91
+ CREATE TABLE IF NOT EXISTS memuron_org_spaces (
92
+ id UUID PRIMARY KEY,
93
+ org_id UUID NOT NULL REFERENCES memuron_orgs(id) ON DELETE CASCADE,
94
+ slug TEXT NOT NULL,
95
+ name TEXT NOT NULL,
96
+ token TEXT NOT NULL,
97
+ description TEXT NOT NULL DEFAULT '',
98
+ guardian_prompt TEXT NOT NULL DEFAULT '',
99
+ is_default BOOLEAN NOT NULL DEFAULT FALSE,
100
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
101
+ UNIQUE (org_id, slug),
102
+ UNIQUE (org_id, token)
103
+ )
104
+ """
105
+ )
106
+ conn.execute(
107
+ """
108
+ CREATE TABLE IF NOT EXISTS memuron_user_space_prefs (
109
+ user_id UUID NOT NULL REFERENCES memuron_users(id) ON DELETE CASCADE,
110
+ space_id UUID NOT NULL REFERENCES memuron_org_spaces(id) ON DELETE CASCADE,
111
+ enabled BOOLEAN NOT NULL DEFAULT FALSE,
112
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
113
+ PRIMARY KEY (user_id, space_id)
114
+ )
115
+ """
116
+ )
117
+ conn.commit()
118
+ self._migrate_clerk_schema(conn)
119
+ return
120
+
121
+ with self._connect_sqlite() as conn:
122
+ conn.executescript(
123
+ """
124
+ CREATE TABLE IF NOT EXISTS memuron_users (
125
+ id TEXT PRIMARY KEY,
126
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
127
+ password_hash TEXT NOT NULL,
128
+ name TEXT NOT NULL,
129
+ created_at TEXT NOT NULL
130
+ );
131
+ CREATE TABLE IF NOT EXISTS memuron_orgs (
132
+ id TEXT PRIMARY KEY,
133
+ slug TEXT NOT NULL UNIQUE COLLATE NOCASE,
134
+ name TEXT NOT NULL,
135
+ created_at TEXT NOT NULL
136
+ );
137
+ CREATE TABLE IF NOT EXISTS memuron_org_memberships (
138
+ org_id TEXT NOT NULL,
139
+ user_id TEXT NOT NULL,
140
+ role TEXT NOT NULL DEFAULT 'member',
141
+ created_at TEXT NOT NULL,
142
+ PRIMARY KEY (org_id, user_id),
143
+ FOREIGN KEY (org_id) REFERENCES memuron_orgs(id) ON DELETE CASCADE,
144
+ FOREIGN KEY (user_id) REFERENCES memuron_users(id) ON DELETE CASCADE
145
+ );
146
+ CREATE TABLE IF NOT EXISTS memuron_org_spaces (
147
+ id TEXT PRIMARY KEY,
148
+ org_id TEXT NOT NULL,
149
+ slug TEXT NOT NULL COLLATE NOCASE,
150
+ name TEXT NOT NULL,
151
+ token TEXT NOT NULL,
152
+ description TEXT NOT NULL DEFAULT '',
153
+ guardian_prompt TEXT NOT NULL DEFAULT '',
154
+ is_default INTEGER NOT NULL DEFAULT 0,
155
+ created_at TEXT NOT NULL,
156
+ UNIQUE (org_id, slug),
157
+ UNIQUE (org_id, token),
158
+ FOREIGN KEY (org_id) REFERENCES memuron_orgs(id) ON DELETE CASCADE
159
+ );
160
+ CREATE TABLE IF NOT EXISTS memuron_user_space_prefs (
161
+ user_id TEXT NOT NULL,
162
+ space_id TEXT NOT NULL,
163
+ enabled INTEGER NOT NULL DEFAULT 0,
164
+ updated_at TEXT NOT NULL,
165
+ PRIMARY KEY (user_id, space_id),
166
+ FOREIGN KEY (user_id) REFERENCES memuron_users(id) ON DELETE CASCADE,
167
+ FOREIGN KEY (space_id) REFERENCES memuron_org_spaces(id) ON DELETE CASCADE
168
+ );
169
+ """
170
+ )
171
+ conn.commit()
172
+ self._migrate_clerk_schema_sqlite(conn)
173
+
174
+ def _migrate_clerk_schema(self, conn: psycopg.Connection[Any]) -> None:
175
+ conn.execute(
176
+ "ALTER TABLE IF EXISTS memuron_org_spaces "
177
+ "DROP CONSTRAINT IF EXISTS memuron_org_spaces_org_id_fkey"
178
+ )
179
+ conn.execute(
180
+ "ALTER TABLE IF EXISTS memuron_user_space_prefs "
181
+ "DROP CONSTRAINT IF EXISTS memuron_user_space_prefs_user_id_fkey"
182
+ )
183
+ conn.execute(
184
+ "ALTER TABLE IF EXISTS memuron_user_space_prefs "
185
+ "DROP CONSTRAINT IF EXISTS memuron_user_space_prefs_space_id_fkey"
186
+ )
187
+ conn.execute(
188
+ """
189
+ ALTER TABLE memuron_org_spaces
190
+ ALTER COLUMN org_id TYPE TEXT USING org_id::text
191
+ """
192
+ )
193
+ conn.execute(
194
+ """
195
+ ALTER TABLE memuron_user_space_prefs
196
+ ALTER COLUMN user_id TYPE TEXT USING user_id::text
197
+ """
198
+ )
199
+ conn.commit()
200
+
201
+ def _migrate_clerk_schema_sqlite(self, conn: sqlite3.Connection) -> None:
202
+ _ = conn
203
+
204
+ def ensure_org_spaces(self, org_id: str, user_id: str) -> list[dict[str, Any]]:
205
+ spaces = self.ensure_default_spaces(org_id)
206
+ if user_id != "system":
207
+ for space in spaces:
208
+ if space.get("is_default"):
209
+ self.seed_space_pref_for_user(user_id, space["id"], enabled=True)
210
+ break
211
+ else:
212
+ if spaces:
213
+ self.seed_space_pref_for_user(user_id, spaces[0]["id"], enabled=True)
214
+ self._invalidate_space_cache()
215
+ if user_id == "system":
216
+ return spaces
217
+ return self.list_user_org_spaces(user_id, org_id)
218
+
219
+ @contextmanager
220
+ def _connect_postgres(self) -> Iterator[psycopg.Connection[Any]]:
221
+ if self._pool is None:
222
+ raise RuntimeError("PostgreSQL pool is unavailable")
223
+ with self._pool.connection() as conn:
224
+ yield conn
225
+
226
+ def close(self) -> None:
227
+ with self._space_cache_lock:
228
+ self._space_cache.clear()
229
+ if self._pool is not None:
230
+ self._pool.close()
231
+ self._pool = None
232
+
233
+ def _invalidate_space_cache(self) -> None:
234
+ with self._space_cache_lock:
235
+ self._space_cache.clear()
236
+
237
+ @contextmanager
238
+ def _connect_sqlite(self) -> Iterator[sqlite3.Connection]:
239
+ path = Path(self.database_target)
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ conn = sqlite3.connect(path)
242
+ conn.row_factory = sqlite3.Row
243
+ try:
244
+ yield conn
245
+ finally:
246
+ conn.close()
247
+
248
+ def _public_user(self, row: dict[str, Any]) -> dict[str, Any]:
249
+ return {
250
+ "id": str(row["id"]),
251
+ "email": str(row["email"]),
252
+ "name": str(row["name"]),
253
+ "created_at": str(row["created_at"]),
254
+ }
255
+
256
+ def _public_org(self, row: dict[str, Any]) -> dict[str, Any]:
257
+ return {
258
+ "id": str(row["id"]),
259
+ "slug": str(row["slug"]),
260
+ "name": str(row["name"]),
261
+ "created_at": str(row["created_at"]),
262
+ }
263
+
264
+ def create_user(self, *, email: str, password_hash: str, name: str) -> dict[str, Any]:
265
+ user_id = str(uuid4())
266
+ now = _utcnow().isoformat()
267
+ normalized_email = email.strip().lower()
268
+ if self.is_postgres:
269
+ with self._connect_postgres() as conn:
270
+ try:
271
+ row = conn.execute(
272
+ """
273
+ INSERT INTO memuron_users (id, email, password_hash, name, created_at)
274
+ VALUES (%s, %s, %s, %s, %s)
275
+ RETURNING *
276
+ """,
277
+ (user_id, normalized_email, password_hash, name.strip(), now),
278
+ ).fetchone()
279
+ conn.commit()
280
+ except psycopg.errors.UniqueViolation as exc:
281
+ raise ValueError("email already registered") from exc
282
+ return self._public_user(row)
283
+ with self._connect_sqlite() as conn:
284
+ try:
285
+ conn.execute(
286
+ """
287
+ INSERT INTO memuron_users (id, email, password_hash, name, created_at)
288
+ VALUES (?, ?, ?, ?, ?)
289
+ """,
290
+ (user_id, normalized_email, password_hash, name.strip(), now),
291
+ )
292
+ conn.commit()
293
+ except sqlite3.IntegrityError as exc:
294
+ raise ValueError("email already registered") from exc
295
+ row = conn.execute(
296
+ "SELECT * FROM memuron_users WHERE id = ?",
297
+ (user_id,),
298
+ ).fetchone()
299
+ return self._public_user(dict(row))
300
+
301
+ def get_user_by_email(self, email: str) -> dict[str, Any] | None:
302
+ normalized_email = email.strip().lower()
303
+ if self.is_postgres:
304
+ with self._connect_postgres() as conn:
305
+ row = conn.execute(
306
+ "SELECT * FROM memuron_users WHERE lower(email) = %s",
307
+ (normalized_email,),
308
+ ).fetchone()
309
+ return dict(row) if row else None
310
+ with self._connect_sqlite() as conn:
311
+ row = conn.execute(
312
+ "SELECT * FROM memuron_users WHERE lower(email) = ?",
313
+ (normalized_email,),
314
+ ).fetchone()
315
+ return dict(row) if row else None
316
+
317
+ def get_user_by_id(self, user_id: str) -> dict[str, Any] | None:
318
+ if self.is_postgres:
319
+ with self._connect_postgres() as conn:
320
+ row = conn.execute(
321
+ "SELECT * FROM memuron_users WHERE id = %s",
322
+ (user_id,),
323
+ ).fetchone()
324
+ return dict(row) if row else None
325
+ with self._connect_sqlite() as conn:
326
+ row = conn.execute(
327
+ "SELECT * FROM memuron_users WHERE id = ?",
328
+ (user_id,),
329
+ ).fetchone()
330
+ return dict(row) if row else None
331
+
332
+ def _unique_org_slug(self, base_slug: str) -> str:
333
+ slug = _slugify(base_slug)
334
+ candidate = slug
335
+ suffix = 1
336
+ while self.get_org_by_slug(candidate) is not None:
337
+ candidate = f"{slug}-{suffix}"
338
+ suffix += 1
339
+ return candidate
340
+
341
+ def create_org(self, *, name: str, slug: str | None = None) -> dict[str, Any]:
342
+ org_id = str(uuid4())
343
+ now = _utcnow().isoformat()
344
+ org_slug = self._unique_org_slug(slug or name)
345
+ if self.is_postgres:
346
+ with self._connect_postgres() as conn:
347
+ row = conn.execute(
348
+ """
349
+ INSERT INTO memuron_orgs (id, slug, name, created_at)
350
+ VALUES (%s, %s, %s, %s)
351
+ RETURNING *
352
+ """,
353
+ (org_id, org_slug, name.strip(), now),
354
+ ).fetchone()
355
+ conn.commit()
356
+ return self._public_org(row)
357
+ with self._connect_sqlite() as conn:
358
+ conn.execute(
359
+ """
360
+ INSERT INTO memuron_orgs (id, slug, name, created_at)
361
+ VALUES (?, ?, ?, ?)
362
+ """,
363
+ (org_id, org_slug, name.strip(), now),
364
+ )
365
+ conn.commit()
366
+ row = conn.execute(
367
+ "SELECT * FROM memuron_orgs WHERE id = ?",
368
+ (org_id,),
369
+ ).fetchone()
370
+ return self._public_org(dict(row))
371
+
372
+ def get_org_by_id(self, org_id: str) -> dict[str, Any] | None:
373
+ if self.is_postgres:
374
+ with self._connect_postgres() as conn:
375
+ row = conn.execute(
376
+ "SELECT * FROM memuron_orgs WHERE id = %s",
377
+ (org_id,),
378
+ ).fetchone()
379
+ return self._public_org(row) if row else None
380
+ with self._connect_sqlite() as conn:
381
+ row = conn.execute(
382
+ "SELECT * FROM memuron_orgs WHERE id = ?",
383
+ (org_id,),
384
+ ).fetchone()
385
+ return self._public_org(dict(row)) if row else None
386
+
387
+ def get_org_by_slug(self, slug: str) -> dict[str, Any] | None:
388
+ if self.is_postgres:
389
+ with self._connect_postgres() as conn:
390
+ row = conn.execute(
391
+ "SELECT * FROM memuron_orgs WHERE slug = %s",
392
+ (slug,),
393
+ ).fetchone()
394
+ return self._public_org(row) if row else None
395
+ with self._connect_sqlite() as conn:
396
+ row = conn.execute(
397
+ "SELECT * FROM memuron_orgs WHERE slug = ?",
398
+ (slug,),
399
+ ).fetchone()
400
+ return self._public_org(dict(row)) if row else None
401
+
402
+ def add_membership(self, *, org_id: str, user_id: str, role: str = "member") -> None:
403
+ now = _utcnow().isoformat()
404
+ if self.is_postgres:
405
+ with self._connect_postgres() as conn:
406
+ conn.execute(
407
+ """
408
+ INSERT INTO memuron_org_memberships (org_id, user_id, role, created_at)
409
+ VALUES (%s, %s, %s, %s)
410
+ ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role
411
+ """,
412
+ (org_id, user_id, role, now),
413
+ )
414
+ conn.commit()
415
+ return
416
+ with self._connect_sqlite() as conn:
417
+ conn.execute(
418
+ """
419
+ INSERT INTO memuron_org_memberships (org_id, user_id, role, created_at)
420
+ VALUES (?, ?, ?, ?)
421
+ ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role
422
+ """,
423
+ (org_id, user_id, role, now),
424
+ )
425
+ conn.commit()
426
+
427
+ def get_membership(self, *, org_id: str, user_id: str) -> dict[str, Any] | None:
428
+ if self.is_postgres:
429
+ with self._connect_postgres() as conn:
430
+ row = conn.execute(
431
+ """
432
+ SELECT role, created_at FROM memuron_org_memberships
433
+ WHERE org_id = %s AND user_id = %s
434
+ """,
435
+ (org_id, user_id),
436
+ ).fetchone()
437
+ return dict(row) if row else None
438
+ with self._connect_sqlite() as conn:
439
+ row = conn.execute(
440
+ """
441
+ SELECT role, created_at FROM memuron_org_memberships
442
+ WHERE org_id = ? AND user_id = ?
443
+ """,
444
+ (org_id, user_id),
445
+ ).fetchone()
446
+ return dict(row) if row else None
447
+
448
+ def list_user_orgs(self, user_id: str) -> list[dict[str, Any]]:
449
+ if self.is_postgres:
450
+ with self._connect_postgres() as conn:
451
+ rows = conn.execute(
452
+ """
453
+ SELECT o.id, o.slug, o.name, o.created_at, m.role
454
+ FROM memuron_org_memberships m
455
+ JOIN memuron_orgs o ON o.id = m.org_id
456
+ WHERE m.user_id = %s
457
+ ORDER BY o.name ASC
458
+ """,
459
+ (user_id,),
460
+ ).fetchall()
461
+ else:
462
+ with self._connect_sqlite() as conn:
463
+ rows = conn.execute(
464
+ """
465
+ SELECT o.id, o.slug, o.name, o.created_at, m.role
466
+ FROM memuron_org_memberships m
467
+ JOIN memuron_orgs o ON o.id = m.org_id
468
+ WHERE m.user_id = ?
469
+ ORDER BY o.name ASC
470
+ """,
471
+ (user_id,),
472
+ ).fetchall()
473
+ return [
474
+ {
475
+ **self._public_org(dict(row)),
476
+ "role": str(row["role"]),
477
+ }
478
+ for row in rows
479
+ ]
480
+
481
+ def _public_space(self, row: dict[str, Any], *, is_enabled: bool | None = None) -> dict[str, Any]:
482
+ is_default = row.get("is_default")
483
+ if isinstance(is_default, int):
484
+ is_default = bool(is_default)
485
+ payload = {
486
+ "id": str(row["id"]),
487
+ "org_id": str(row["org_id"]),
488
+ "slug": str(row["slug"]),
489
+ "name": str(row["name"]),
490
+ "token": str(row["token"]),
491
+ "description": str(row.get("description") or ""),
492
+ "guardian_prompt": str(row.get("guardian_prompt") or ""),
493
+ "is_default": bool(is_default),
494
+ "created_at": str(row["created_at"]),
495
+ }
496
+ if is_enabled is not None:
497
+ payload["is_enabled"] = is_enabled
498
+ return payload
499
+
500
+ def _unique_space_slug(self, org_id: str, base_slug: str) -> str:
501
+ slug = slugify_space(base_slug)
502
+ candidate = slug
503
+ suffix = 1
504
+ while self.get_space_by_slug(org_id, candidate) is not None:
505
+ candidate = f"{slug}-{suffix}"
506
+ suffix += 1
507
+ return candidate
508
+
509
+ def create_space(
510
+ self,
511
+ *,
512
+ org_id: str,
513
+ name: str,
514
+ slug: str | None = None,
515
+ description: str = "",
516
+ guardian_prompt: str = "",
517
+ is_default: bool = False,
518
+ ) -> dict[str, Any]:
519
+ space_id = str(uuid4())
520
+ now = _utcnow().isoformat()
521
+ space_slug = self._unique_space_slug(org_id, slug or name)
522
+ token = compile_space_token(space_slug)
523
+ if is_default:
524
+ self._clear_default_space(org_id)
525
+ if self.is_postgres:
526
+ with self._connect_postgres() as conn:
527
+ row = conn.execute(
528
+ """
529
+ INSERT INTO memuron_org_spaces (
530
+ id, org_id, slug, name, token, description, guardian_prompt,
531
+ is_default, created_at
532
+ )
533
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
534
+ RETURNING *
535
+ """,
536
+ (
537
+ space_id,
538
+ org_id,
539
+ space_slug,
540
+ name.strip(),
541
+ token,
542
+ description.strip(),
543
+ guardian_prompt.strip(),
544
+ is_default,
545
+ now,
546
+ ),
547
+ ).fetchone()
548
+ conn.commit()
549
+ created = self._public_space(row)
550
+ self._invalidate_space_cache()
551
+ return created
552
+ with self._connect_sqlite() as conn:
553
+ conn.execute(
554
+ """
555
+ INSERT INTO memuron_org_spaces (
556
+ id, org_id, slug, name, token, description, guardian_prompt,
557
+ is_default, created_at
558
+ )
559
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
560
+ """,
561
+ (
562
+ space_id,
563
+ org_id,
564
+ space_slug,
565
+ name.strip(),
566
+ token,
567
+ description.strip(),
568
+ guardian_prompt.strip(),
569
+ 1 if is_default else 0,
570
+ now,
571
+ ),
572
+ )
573
+ conn.commit()
574
+ row = conn.execute(
575
+ "SELECT * FROM memuron_org_spaces WHERE id = ?",
576
+ (space_id,),
577
+ ).fetchone()
578
+ created = self._public_space(dict(row))
579
+ self._invalidate_space_cache()
580
+ return created
581
+
582
+ def _clear_default_space(self, org_id: str) -> None:
583
+ if self.is_postgres:
584
+ with self._connect_postgres() as conn:
585
+ conn.execute(
586
+ "UPDATE memuron_org_spaces SET is_default = FALSE WHERE org_id = %s",
587
+ (org_id,),
588
+ )
589
+ conn.commit()
590
+ return
591
+ with self._connect_sqlite() as conn:
592
+ conn.execute(
593
+ "UPDATE memuron_org_spaces SET is_default = 0 WHERE org_id = ?",
594
+ (org_id,),
595
+ )
596
+ conn.commit()
597
+
598
+ def ensure_default_spaces(self, org_id: str) -> list[dict[str, Any]]:
599
+ spaces = self.list_org_spaces(org_id)
600
+ if spaces:
601
+ return spaces
602
+ defaults = default_personal_space(org_id=org_id)
603
+ created = self.create_space(
604
+ org_id=org_id,
605
+ name=defaults["name"],
606
+ slug=defaults["slug"],
607
+ description=defaults["description"],
608
+ guardian_prompt=defaults["guardian_prompt"],
609
+ is_default=True,
610
+ )
611
+ return [created]
612
+
613
+ def list_org_spaces(self, org_id: str) -> list[dict[str, Any]]:
614
+ if self.is_postgres:
615
+ with self._connect_postgres() as conn:
616
+ rows = conn.execute(
617
+ """
618
+ SELECT * FROM memuron_org_spaces
619
+ WHERE org_id = %s
620
+ ORDER BY is_default DESC, name ASC
621
+ """,
622
+ (org_id,),
623
+ ).fetchall()
624
+ else:
625
+ with self._connect_sqlite() as conn:
626
+ rows = conn.execute(
627
+ """
628
+ SELECT * FROM memuron_org_spaces
629
+ WHERE org_id = ?
630
+ ORDER BY is_default DESC, name ASC
631
+ """,
632
+ (org_id,),
633
+ ).fetchall()
634
+ return [self._public_space(dict(row)) for row in rows]
635
+
636
+ def _load_space_prefs(self, user_id: str, space_ids: list[str]) -> dict[str, bool]:
637
+ if not space_ids:
638
+ return {}
639
+ if self.is_postgres:
640
+ with self._connect_postgres() as conn:
641
+ rows = conn.execute(
642
+ """
643
+ SELECT space_id, enabled FROM memuron_user_space_prefs
644
+ WHERE user_id = %s AND space_id = ANY(%s)
645
+ """,
646
+ (user_id, space_ids),
647
+ ).fetchall()
648
+ else:
649
+ placeholders = ",".join("?" for _ in space_ids)
650
+ with self._connect_sqlite() as conn:
651
+ rows = conn.execute(
652
+ f"""
653
+ SELECT space_id, enabled FROM memuron_user_space_prefs
654
+ WHERE user_id = ? AND space_id IN ({placeholders})
655
+ """,
656
+ (user_id, *space_ids),
657
+ ).fetchall()
658
+ prefs: dict[str, bool] = {}
659
+ for row in rows:
660
+ enabled = row["enabled"]
661
+ if isinstance(enabled, int):
662
+ enabled = bool(enabled)
663
+ prefs[str(row["space_id"])] = bool(enabled)
664
+ return prefs
665
+
666
+ def _upsert_space_pref(self, user_id: str, space_id: str, enabled: bool) -> None:
667
+ now = _utcnow().isoformat()
668
+ if self.is_postgres:
669
+ with self._connect_postgres() as conn:
670
+ conn.execute(
671
+ """
672
+ INSERT INTO memuron_user_space_prefs (user_id, space_id, enabled, updated_at)
673
+ VALUES (%s, %s, %s, %s)
674
+ ON CONFLICT (user_id, space_id)
675
+ DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at
676
+ """,
677
+ (user_id, space_id, enabled, now),
678
+ )
679
+ conn.commit()
680
+ return
681
+ with self._connect_sqlite() as conn:
682
+ conn.execute(
683
+ """
684
+ INSERT INTO memuron_user_space_prefs (user_id, space_id, enabled, updated_at)
685
+ VALUES (?, ?, ?, ?)
686
+ ON CONFLICT (user_id, space_id)
687
+ DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at
688
+ """,
689
+ (user_id, space_id, 1 if enabled else 0, now),
690
+ )
691
+ conn.commit()
692
+
693
+ def list_user_org_spaces(self, user_id: str, org_id: str) -> list[dict[str, Any]]:
694
+ cache_key = (user_id, org_id)
695
+ now = monotonic()
696
+ with self._space_cache_lock:
697
+ cached = self._space_cache.get(cache_key)
698
+ if cached is not None and cached[0] > now:
699
+ return [dict(space) for space in cached[1]]
700
+
701
+ if self.is_postgres:
702
+ with self._connect_postgres() as conn:
703
+ rows = conn.execute(
704
+ """
705
+ SELECT s.*, p.enabled AS pref_enabled
706
+ FROM memuron_org_spaces s
707
+ LEFT JOIN memuron_user_space_prefs p
708
+ ON p.space_id = s.id AND p.user_id = %s
709
+ WHERE s.org_id = %s
710
+ ORDER BY s.is_default DESC, s.name ASC
711
+ """,
712
+ (user_id, org_id),
713
+ ).fetchall()
714
+ else:
715
+ with self._connect_sqlite() as conn:
716
+ rows = conn.execute(
717
+ """
718
+ SELECT s.*, p.enabled AS pref_enabled
719
+ FROM memuron_org_spaces s
720
+ LEFT JOIN memuron_user_space_prefs p
721
+ ON p.space_id = s.id AND p.user_id = ?
722
+ WHERE s.org_id = ?
723
+ ORDER BY s.is_default DESC, s.name ASC
724
+ """,
725
+ (user_id, org_id),
726
+ ).fetchall()
727
+ if not rows:
728
+ self.ensure_default_spaces(org_id)
729
+ return self.list_user_org_spaces(user_id, org_id)
730
+
731
+ output: list[dict[str, Any]] = []
732
+ for raw_row in rows:
733
+ space = dict(raw_row)
734
+ enabled = space.pop("pref_enabled", None)
735
+ if enabled is None:
736
+ enabled = bool(space["is_default"])
737
+ self._upsert_space_pref(user_id, str(space["id"]), enabled)
738
+ output.append(self._public_space(space, is_enabled=bool(enabled)))
739
+ with self._space_cache_lock:
740
+ self._space_cache[cache_key] = (
741
+ monotonic() + 30.0,
742
+ [dict(space) for space in output],
743
+ )
744
+ return [dict(space) for space in output]
745
+
746
+ def get_enabled_org_spaces(self, user_id: str, org_id: str) -> list[dict[str, Any]]:
747
+ return [
748
+ space
749
+ for space in self.list_user_org_spaces(user_id, org_id)
750
+ if space.get("is_enabled")
751
+ ]
752
+
753
+ def set_space_enabled(
754
+ self,
755
+ user_id: str,
756
+ org_id: str,
757
+ space_id: str,
758
+ *,
759
+ enabled: bool,
760
+ ) -> dict[str, Any]:
761
+ space = self.get_space_by_id(space_id)
762
+ if space is None or space["org_id"] != org_id:
763
+ raise ValueError("space not found in organization")
764
+ if not enabled:
765
+ enabled_spaces = self.get_enabled_org_spaces(user_id, org_id)
766
+ if len(enabled_spaces) <= 1 and any(item["id"] == space_id for item in enabled_spaces):
767
+ raise ValueError("at least one space must stay enabled")
768
+ self._upsert_space_pref(user_id, space_id, enabled)
769
+ self._invalidate_space_cache()
770
+ return self._public_space(space, is_enabled=enabled)
771
+
772
+ def seed_space_pref_for_user(
773
+ self,
774
+ user_id: str,
775
+ space_id: str,
776
+ *,
777
+ enabled: bool,
778
+ ) -> None:
779
+ prefs = self._load_space_prefs(user_id, [space_id])
780
+ if space_id in prefs:
781
+ return
782
+ self._upsert_space_pref(user_id, space_id, enabled)
783
+ self._invalidate_space_cache()
784
+
785
+ def get_space_by_id(self, space_id: str) -> dict[str, Any] | None:
786
+ if self.is_postgres:
787
+ with self._connect_postgres() as conn:
788
+ row = conn.execute(
789
+ "SELECT * FROM memuron_org_spaces WHERE id = %s",
790
+ (space_id,),
791
+ ).fetchone()
792
+ return self._public_space(row) if row else None
793
+ with self._connect_sqlite() as conn:
794
+ row = conn.execute(
795
+ "SELECT * FROM memuron_org_spaces WHERE id = ?",
796
+ (space_id,),
797
+ ).fetchone()
798
+ return self._public_space(dict(row)) if row else None
799
+
800
+ def get_space_by_slug(self, org_id: str, slug: str) -> dict[str, Any] | None:
801
+ if self.is_postgres:
802
+ with self._connect_postgres() as conn:
803
+ row = conn.execute(
804
+ """
805
+ SELECT * FROM memuron_org_spaces
806
+ WHERE org_id = %s AND slug = %s
807
+ """,
808
+ (org_id, slug),
809
+ ).fetchone()
810
+ return self._public_space(row) if row else None
811
+ with self._connect_sqlite() as conn:
812
+ row = conn.execute(
813
+ """
814
+ SELECT * FROM memuron_org_spaces
815
+ WHERE org_id = ? AND slug = ?
816
+ """,
817
+ (org_id, slug),
818
+ ).fetchone()
819
+ return self._public_space(dict(row)) if row else None
820
+
821
+ def get_default_space(self, org_id: str) -> dict[str, Any] | None:
822
+ spaces = self.ensure_default_spaces(org_id)
823
+ for space in spaces:
824
+ if space["is_default"]:
825
+ return space
826
+ return spaces[0] if spaces else None
827
+
828
+ def set_default_space(self, org_id: str, space_id: str) -> dict[str, Any]:
829
+ space = self.get_space_by_id(space_id)
830
+ if space is None or space["org_id"] != org_id:
831
+ raise ValueError("space not found in organization")
832
+ self._clear_default_space(org_id)
833
+ if self.is_postgres:
834
+ with self._connect_postgres() as conn:
835
+ row = conn.execute(
836
+ """
837
+ UPDATE memuron_org_spaces
838
+ SET is_default = TRUE
839
+ WHERE id = %s AND org_id = %s
840
+ RETURNING *
841
+ """,
842
+ (space_id, org_id),
843
+ ).fetchone()
844
+ conn.commit()
845
+ updated = self._public_space(row)
846
+ self._invalidate_space_cache()
847
+ return updated
848
+ with self._connect_sqlite() as conn:
849
+ conn.execute(
850
+ """
851
+ UPDATE memuron_org_spaces
852
+ SET is_default = 1
853
+ WHERE id = ? AND org_id = ?
854
+ """,
855
+ (space_id, org_id),
856
+ )
857
+ conn.commit()
858
+ row = conn.execute(
859
+ "SELECT * FROM memuron_org_spaces WHERE id = ?",
860
+ (space_id,),
861
+ ).fetchone()
862
+ updated = self._public_space(dict(row))
863
+ self._invalidate_space_cache()
864
+ return updated
865
+
866
+ def update_space(
867
+ self,
868
+ space_id: str,
869
+ *,
870
+ name: str | None = None,
871
+ description: str | None = None,
872
+ guardian_prompt: str | None = None,
873
+ ) -> dict[str, Any]:
874
+ space = self.get_space_by_id(space_id)
875
+ if space is None:
876
+ raise ValueError("space not found")
877
+ updates: dict[str, Any] = {}
878
+ if name is not None:
879
+ updates["name"] = name.strip()
880
+ if description is not None:
881
+ updates["description"] = description.strip()
882
+ if guardian_prompt is not None:
883
+ updates["guardian_prompt"] = guardian_prompt.strip()
884
+ if not updates:
885
+ return space
886
+ if self.is_postgres:
887
+ set_clause = ", ".join(f"{key} = %s" for key in updates)
888
+ with self._connect_postgres() as conn:
889
+ row = conn.execute(
890
+ f"UPDATE memuron_org_spaces SET {set_clause} WHERE id = %s RETURNING *",
891
+ (*updates.values(), space_id),
892
+ ).fetchone()
893
+ conn.commit()
894
+ updated = self._public_space(row)
895
+ self._invalidate_space_cache()
896
+ return updated
897
+ set_clause = ", ".join(f"{key} = ?" for key in updates)
898
+ with self._connect_sqlite() as conn:
899
+ conn.execute(
900
+ f"UPDATE memuron_org_spaces SET {set_clause} WHERE id = ?",
901
+ (*updates.values(), space_id),
902
+ )
903
+ conn.commit()
904
+ row = conn.execute(
905
+ "SELECT * FROM memuron_org_spaces WHERE id = ?",
906
+ (space_id,),
907
+ ).fetchone()
908
+ updated = self._public_space(dict(row))
909
+ self._invalidate_space_cache()
910
+ return updated
911
+
912
+ def dump_state(self) -> str:
913
+ return json.dumps(
914
+ {
915
+ "database_target": self.database_target,
916
+ "is_postgres": self.is_postgres,
917
+ }
918
+ )