memgres 0.13.2__tar.gz → 0.14.0__tar.gz

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 (166) hide show
  1. {memgres-0.13.2 → memgres-0.14.0}/PKG-INFO +1 -1
  2. {memgres-0.13.2 → memgres-0.14.0}/memgres/_version.py +1 -1
  3. {memgres-0.13.2 → memgres-0.14.0}/memgres/identity.py +19 -0
  4. {memgres-0.13.2 → memgres-0.14.0}/memgres/mcp_server.py +5 -1
  5. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/people.py +144 -2
  6. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/routes.py +7 -0
  7. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/spaces.py +32 -1
  8. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/app.css +21 -0
  9. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/account.js +2 -2
  10. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/admin.js +59 -11
  11. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/memory.js +46 -1
  12. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/people.js +33 -4
  13. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/tokens.js +68 -16
  14. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/locales/en.json +41 -1
  15. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/locales/ru.json +41 -1
  16. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/PKG-INFO +1 -1
  17. {memgres-0.13.2 → memgres-0.14.0}/tests/test_identity_integration.py +21 -0
  18. {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_people.py +118 -1
  19. {memgres-0.13.2 → memgres-0.14.0}/LICENSE +0 -0
  20. {memgres-0.13.2 → memgres-0.14.0}/README.md +0 -0
  21. {memgres-0.13.2 → memgres-0.14.0}/memgres/__init__.py +0 -0
  22. {memgres-0.13.2 → memgres-0.14.0}/memgres/admin.py +0 -0
  23. {memgres-0.13.2 → memgres-0.14.0}/memgres/admin_cli.py +0 -0
  24. {memgres-0.13.2 → memgres-0.14.0}/memgres/blame.py +0 -0
  25. {memgres-0.13.2 → memgres-0.14.0}/memgres/bootstrap.py +0 -0
  26. {memgres-0.13.2 → memgres-0.14.0}/memgres/config.py +0 -0
  27. {memgres-0.13.2 → memgres-0.14.0}/memgres/delimiters.py +0 -0
  28. {memgres-0.13.2 → memgres-0.14.0}/memgres/diffing.py +0 -0
  29. {memgres-0.13.2 → memgres-0.14.0}/memgres/embed_worker.py +0 -0
  30. {memgres-0.13.2 → memgres-0.14.0}/memgres/embeddings.py +0 -0
  31. {memgres-0.13.2 → memgres-0.14.0}/memgres/healthcheck.py +0 -0
  32. {memgres-0.13.2 → memgres-0.14.0}/memgres/indexing.py +0 -0
  33. {memgres-0.13.2 → memgres-0.14.0}/memgres/info.py +0 -0
  34. {memgres-0.13.2 → memgres-0.14.0}/memgres/lines.py +0 -0
  35. {memgres-0.13.2 → memgres-0.14.0}/memgres/links.py +0 -0
  36. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0001_core.sql +0 -0
  37. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0002_identity.sql +0 -0
  38. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0003_history_author.sql +0 -0
  39. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0004_title.sql +0 -0
  40. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  41. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  42. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  43. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0008_service_roles.sql +0 -0
  44. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  45. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  46. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  47. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0012_user_profile.sql +0 -0
  48. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0013_hash_version.sql +0 -0
  49. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  50. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  51. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0016_valid_at.sql +0 -0
  52. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0017_memory_link.sql +0 -0
  53. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0018_links_built.sql +0 -0
  54. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  55. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  56. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
  57. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0022_user_disabled.sql +0 -0
  58. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
  59. {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0024_web_panel.sql +0 -0
  60. {memgres-0.13.2 → memgres-0.14.0}/memgres/paths.py +0 -0
  61. {memgres-0.13.2 → memgres-0.14.0}/memgres/periodic.py +0 -0
  62. {memgres-0.13.2 → memgres-0.14.0}/memgres/reembed.py +0 -0
  63. {memgres-0.13.2 → memgres-0.14.0}/memgres/relink.py +0 -0
  64. {memgres-0.13.2 → memgres-0.14.0}/memgres/schema.py +0 -0
  65. {memgres-0.13.2 → memgres-0.14.0}/memgres/search.py +0 -0
  66. {memgres-0.13.2 → memgres-0.14.0}/memgres/segments.py +0 -0
  67. {memgres-0.13.2 → memgres-0.14.0}/memgres/server.py +0 -0
  68. {memgres-0.13.2 → memgres-0.14.0}/memgres/store.py +0 -0
  69. {memgres-0.13.2 → memgres-0.14.0}/memgres/tags.py +0 -0
  70. {memgres-0.13.2 → memgres-0.14.0}/memgres/token_cli.py +0 -0
  71. {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/__init__.py +0 -0
  72. {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/base.py +0 -0
  73. {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/pgvector.py +0 -0
  74. {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/qdrant.py +0 -0
  75. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/__init__.py +0 -0
  76. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/admission.py +0 -0
  77. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/auth_routes.py +0 -0
  78. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/memory.py +0 -0
  79. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/oidc.py +0 -0
  80. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/oidc_config.py +0 -0
  81. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/sessions.py +0 -0
  82. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/OFL-JetBrainsMono.txt +0 -0
  83. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/OFL-Onest.txt +0 -0
  84. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
  85. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
  86. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  87. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  88. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
  89. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
  90. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
  91. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-latin.woff2 +0 -0
  92. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts.css +0 -0
  93. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/index.html +0 -0
  94. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/api.js +0 -0
  95. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/app.js +0 -0
  96. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/gate.js +0 -0
  97. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/i18n.js +0 -0
  98. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/md.js +0 -0
  99. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/signins.js +0 -0
  100. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/space.js +0 -0
  101. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/ui.js +0 -0
  102. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/viz.js +0 -0
  103. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-d3.txt +0 -0
  104. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-dompurify.txt +0 -0
  105. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-marked.txt +0 -0
  106. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/d3.min.js +0 -0
  107. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/marked.esm.js +0 -0
  108. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/purify.es.js +0 -0
  109. {memgres-0.13.2 → memgres-0.14.0}/memgres/web/tokens.py +0 -0
  110. {memgres-0.13.2 → memgres-0.14.0}/memgres/worker.py +0 -0
  111. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/SOURCES.txt +0 -0
  112. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/dependency_links.txt +0 -0
  113. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/entry_points.txt +0 -0
  114. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/requires.txt +0 -0
  115. {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/top_level.txt +0 -0
  116. {memgres-0.13.2 → memgres-0.14.0}/pyproject.toml +0 -0
  117. {memgres-0.13.2 → memgres-0.14.0}/setup.cfg +0 -0
  118. {memgres-0.13.2 → memgres-0.14.0}/tests/test_admin_two_way.py +0 -0
  119. {memgres-0.13.2 → memgres-0.14.0}/tests/test_blame_integration.py +0 -0
  120. {memgres-0.13.2 → memgres-0.14.0}/tests/test_chunk_index.py +0 -0
  121. {memgres-0.13.2 → memgres-0.14.0}/tests/test_claim_and_reembed.py +0 -0
  122. {memgres-0.13.2 → memgres-0.14.0}/tests/test_config.py +0 -0
  123. {memgres-0.13.2 → memgres-0.14.0}/tests/test_diffing.py +0 -0
  124. {memgres-0.13.2 → memgres-0.14.0}/tests/test_embed_worker.py +0 -0
  125. {memgres-0.13.2 → memgres-0.14.0}/tests/test_embeddings.py +0 -0
  126. {memgres-0.13.2 → memgres-0.14.0}/tests/test_enrollment.py +0 -0
  127. {memgres-0.13.2 → memgres-0.14.0}/tests/test_healthcheck.py +0 -0
  128. {memgres-0.13.2 → memgres-0.14.0}/tests/test_lexical_match.py +0 -0
  129. {memgres-0.13.2 → memgres-0.14.0}/tests/test_limits.py +0 -0
  130. {memgres-0.13.2 → memgres-0.14.0}/tests/test_links.py +0 -0
  131. {memgres-0.13.2 → memgres-0.14.0}/tests/test_list.py +0 -0
  132. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_admin_tools.py +0 -0
  133. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_error_messages.py +0 -0
  134. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_http_transport.py +0 -0
  135. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_instructions.py +0 -0
  136. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_recall_schema.py +0 -0
  137. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_tool_visibility.py +0 -0
  138. {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_tool_visibility_http.py +0 -0
  139. {memgres-0.13.2 → memgres-0.14.0}/tests/test_migration_upgrade.py +0 -0
  140. {memgres-0.13.2 → memgres-0.14.0}/tests/test_multi_space_search.py +0 -0
  141. {memgres-0.13.2 → memgres-0.14.0}/tests/test_path_addressing.py +0 -0
  142. {memgres-0.13.2 → memgres-0.14.0}/tests/test_qdrant_ca.py +0 -0
  143. {memgres-0.13.2 → memgres-0.14.0}/tests/test_qdrant_integration.py +0 -0
  144. {memgres-0.13.2 → memgres-0.14.0}/tests/test_replace_build.py +0 -0
  145. {memgres-0.13.2 → memgres-0.14.0}/tests/test_require_title.py +0 -0
  146. {memgres-0.13.2 → memgres-0.14.0}/tests/test_required_fields.py +0 -0
  147. {memgres-0.13.2 → memgres-0.14.0}/tests/test_retention.py +0 -0
  148. {memgres-0.13.2 → memgres-0.14.0}/tests/test_roles_bootstrap.py +0 -0
  149. {memgres-0.13.2 → memgres-0.14.0}/tests/test_search_integration.py +0 -0
  150. {memgres-0.13.2 → memgres-0.14.0}/tests/test_security_followups.py +0 -0
  151. {memgres-0.13.2 → memgres-0.14.0}/tests/test_security_integration.py +0 -0
  152. {memgres-0.13.2 → memgres-0.14.0}/tests/test_segments.py +0 -0
  153. {memgres-0.13.2 → memgres-0.14.0}/tests/test_segments_store.py +0 -0
  154. {memgres-0.13.2 → memgres-0.14.0}/tests/test_server_info.py +0 -0
  155. {memgres-0.13.2 → memgres-0.14.0}/tests/test_server_integration.py +0 -0
  156. {memgres-0.13.2 → memgres-0.14.0}/tests/test_snippets.py +0 -0
  157. {memgres-0.13.2 → memgres-0.14.0}/tests/test_store_integration.py +0 -0
  158. {memgres-0.13.2 → memgres-0.14.0}/tests/test_tags.py +0 -0
  159. {memgres-0.13.2 → memgres-0.14.0}/tests/test_token_sink.py +0 -0
  160. {memgres-0.13.2 → memgres-0.14.0}/tests/test_usage.py +0 -0
  161. {memgres-0.13.2 → memgres-0.14.0}/tests/test_valid_at.py +0 -0
  162. {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_memory.py +0 -0
  163. {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_oidc.py +0 -0
  164. {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_session.py +0 -0
  165. {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_tokens.py +0 -0
  166. {memgres-0.13.2 → memgres-0.14.0}/tests/test_write_ergonomics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.13.2
3
+ Version: 0.14.0
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.13.2"
11
+ __version__ = "0.14.0"
@@ -1006,6 +1006,10 @@ def create_own_namespace(conn, principal: Principal, name: str, *,
1006
1006
  had worked. Creation is now something you ask for, which makes a typo an
1007
1007
  error rather than a place — and gives the `can_create_namespace` right a
1008
1008
  single point to be enforced at.
1009
+
1010
+ Unlike :func:`create_namespace`, which the provisioning doors call and which
1011
+ hands back an existing space of the same name so a script can be re-run,
1012
+ this door REFUSES that name: whoever called it asked for a new space.
1009
1013
  """
1010
1014
  if principal.user_id is None:
1011
1015
  if not principal.provisional:
@@ -1027,6 +1031,21 @@ def create_own_namespace(conn, principal: Principal, name: str, *,
1027
1031
  if not can_create_namespace(conn, principal):
1028
1032
  raise AuthError("you may not create namespaces — ask an admin to create "
1029
1033
  "one for you or share theirs")
1034
+ # `create_namespace` is an idempotent upsert, which is right for the
1035
+ # provisioning doors (a script must be re-runnable) and wrong for this one.
1036
+ # Here the caller is an agent or a person who said "create", and being
1037
+ # handed a space they already had — with `description` and `instruction`
1038
+ # silently NOT applied, because the upsert does nothing on conflict — looks
1039
+ # exactly like success. The refusal names the space, so a retry after a
1040
+ # dropped connection is answered rather than left to guess.
1041
+ with conn.cursor() as cur:
1042
+ cur.execute("SELECT id FROM namespace WHERE owner_user_id=%s AND name=%s",
1043
+ (principal.user_id, name))
1044
+ row = cur.fetchone()
1045
+ if row is not None:
1046
+ raise SpaceAmbiguous(
1047
+ f"you already own a namespace called '{name}' ({row[0]}) — write to "
1048
+ f"it, or pick another name")
1030
1049
  # the alias collision and the per-account cap are enforced in
1031
1050
  # `create_namespace`, so every door gets them
1032
1051
  return create_namespace(conn, principal.user_id, name,
@@ -718,7 +718,11 @@ def build_server(cfg: Optional[Config] = None):
718
718
  """Create a namespace of your own. Nothing creates one implicitly, so a
719
719
  mistyped `space` is an error rather than a new empty space your write
720
720
  silently lands in. `description` says what belongs here and `instruction`
721
- tells an agent how to use it. Needs the right to create namespaces."""
721
+ tells an agent how to use it. Needs the right to create namespaces.
722
+
723
+ A name you already own is refused, and the error names that space: this
724
+ never hands you an existing namespace as if it were new. To change what
725
+ an existing one says, edit it instead of creating it again."""
722
726
  with pool.connection() as conn, conn.transaction():
723
727
  nsid = identity.create_own_namespace(
724
728
  conn, _principal(conn, _token(ctx)), name,
@@ -9,6 +9,13 @@ Who sees what:
9
9
  administrator's account, and only a superadmin hands out roles;
10
10
  * **someone you share a space with** — who they are (name, department,
11
11
  position) and what they wrote in the spaces you can both read;
12
+ * **what someone wrote** — the activity chart and the list of recent edits —
13
+ is only ever shown where the VIEWER can read too: a superadmin reads every
14
+ space, everyone else (a user_manager included) only the spaces they are in.
15
+ so the PAGE never displays records from a space the viewer cannot open.
16
+ That is a rule about this page, not a containment guarantee about the role:
17
+ an administrator who can mint a token FOR someone can read what that someone
18
+ reads, which docs/TENANCY.md states outright;
12
19
  * **anyone else** — not found. There is no directory for ordinary users.
13
20
 
14
21
  Activity is writes only, taken from memory history: what someone created,
@@ -93,6 +100,38 @@ def activity(conn, user_id: str, *, spaces: Optional[list]) -> dict:
93
100
  }
94
101
 
95
102
 
103
+ RECENT_EDITS = 30
104
+
105
+
106
+ def recent_edits(conn, user_id: str, *, spaces: Optional[list]) -> list:
107
+ """The person's latest changes, newest first — record, space, kind, when.
108
+ ``spaces=None`` means every space (a superadmin looking)."""
109
+ params: list = [user_id]
110
+ where = ""
111
+ if spaces is not None:
112
+ if not spaces:
113
+ return []
114
+ where = " AND m.namespace = ANY(%s::text[])"
115
+ params.append(spaces)
116
+ params.append(RECENT_EDITS)
117
+ with conn.cursor() as cur:
118
+ cur.execute(
119
+ "SELECT h.created_at, h.op, m.id::text, m.namespace, m.path::text, m.title, n.name "
120
+ "FROM memory_history h JOIN memory m ON m.id = h.memory_id "
121
+ "JOIN namespace n ON n.id::text = m.namespace "
122
+ "WHERE h.author_user_id = %s" + where + " ORDER BY h.created_at DESC, h.id DESC LIMIT %s", params)
123
+ return [{"at": at, "op": op, "record_id": rid, "space_id": ns, "path": path, "title": title or "",
124
+ "space": sname} for at, op, rid, ns, path, title, sname in cur.fetchall()]
125
+
126
+
127
+ def _viewer_scope(conn, viewer) -> Optional[list]:
128
+ """The spaces whose contents this viewer may see: every one for a superadmin
129
+ (its role reads them all), otherwise its own."""
130
+ if viewer.user_id is None or viewer.role == "superadmin":
131
+ return None
132
+ return _reachable(conn, viewer.user_id)
133
+
134
+
96
135
  def _account(cur, user_id: str):
97
136
  cur.execute("SELECT id, name, full_name, email, department, position, role, "
98
137
  "disabled_at IS NOT NULL, created_at, can_create_namespace FROM app_user WHERE id = %s",
@@ -127,7 +166,8 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
127
166
  (together,))
128
167
  spaces = [{"id": i, "name": n} for i, n in cur.fetchall()]
129
168
  return {"view": "colleague", "person": person, "shared_spaces": spaces,
130
- "activity": activity(conn, str(uid), spaces=together)}
169
+ "activity": activity(conn, str(uid), spaces=together),
170
+ "recent": recent_edits(conn, str(uid), spaces=together)}
131
171
 
132
172
  # who holds authority on the server is not a colleague's business
133
173
  person.update({"role": role, "email": email, "disabled": bool(disabled), "created_at": created,
@@ -141,6 +181,7 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
141
181
  # tokens and sign-in methods are not theirs to see (admin.list_tokens
142
182
  # refuses the same thing).
143
183
  may_manage = is_self or viewer.role == "superadmin" or role not in ADMIN_ROLES
184
+ scope = _viewer_scope(conn, viewer)
144
185
  out = {
145
186
  "view": "self" if is_self else "admin",
146
187
  "person": person,
@@ -149,7 +190,8 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
149
190
  "signins": admission.identities(conn, str(uid), providers) if may_manage else None,
150
191
  "tokens": tokens.list_own(conn, str(uid)) if may_manage else None,
151
192
  "sessions": live_sessions,
152
- "activity": activity(conn, str(uid), spaces=None if is_service_admin else [s["id"] for s in spaces]),
193
+ "activity": activity(conn, str(uid), spaces=scope),
194
+ "recent": recent_edits(conn, str(uid), spaces=scope),
153
195
  }
154
196
  if not is_self:
155
197
  # what this administrator may do to this account, by the rules that
@@ -275,6 +317,86 @@ def edit(conn, p, user_id: str, changes: dict) -> None:
275
317
  _guard(lambda: admin.edit_user(conn, p, user_id=user_id, **fields))
276
318
 
277
319
 
320
+ def create_person(conn, p, fields: dict) -> str:
321
+ """An account made by an administrator — for someone who will sign in later
322
+ (the email is how their first sign-in finds it) or for a service that only
323
+ ever uses tokens. It gets no spaces and no rights: those are separate acts."""
324
+ clean = {}
325
+ for k in ("name", "full_name", "email", "department", "position"):
326
+ v = fields.get(k)
327
+ if v is None:
328
+ continue
329
+ if not isinstance(v, str):
330
+ raise Refused(422, f"{k} must be text")
331
+ v = v.strip()
332
+ if len(v) > 200:
333
+ raise Refused(422, f"{k} is longer than 200 characters")
334
+ if v:
335
+ clean[k] = v
336
+ if "email" in clean:
337
+ from .spaces import Refused as SpaceRefused, normalize_email
338
+ try:
339
+ clean["email"] = normalize_email(clean["email"])
340
+ except SpaceRefused as e:
341
+ raise Refused(422, str(e)) from None
342
+ with conn.cursor() as cur:
343
+ cur.execute("SELECT 1 FROM app_user WHERE lower(email) = lower(%s)", (clean["email"],))
344
+ if cur.fetchone():
345
+ raise Refused(409, "another account already has this email")
346
+ if not (clean.get("full_name") or clean.get("email") or clean.get("name")):
347
+ raise Refused(422, "give the account a name or an email")
348
+ name = clean.pop("name", None) or clean.get("email") or clean.get("full_name")
349
+ return _guard(lambda: admin.create_user(conn, p, name=name, **clean))
350
+
351
+
352
+ def set_can_create_spaces(conn, p, user_id: str, allowed: bool) -> None:
353
+ user_id = _uuid(user_id)
354
+ _guard(lambda: admin.set_can_create_namespace(conn, p, user_id=user_id, allowed=bool(allowed)))
355
+
356
+
357
+ def issue_for(conn, p, user_id: str, *, label: str, permission: str,
358
+ namespace_id: Optional[str], expires_days: int) -> dict:
359
+ """A token for someone else — the same shape a person gets for themselves
360
+ (read or write, always expiring, optionally one of their spaces), minted by
361
+ an administrator for an account that cannot do it itself: a service, or
362
+ someone who has not signed in yet. The secret is shown once, to the
363
+ administrator, who hands it over — the same exemption from
364
+ ``MEMGRES_TOKEN_SINK`` the self-service door takes, and for the same reason:
365
+ the sink keeps secrets out of *agent* transcripts, and the reader here is a
366
+ human looking at a dialog that says "copy it now".
367
+
368
+ The cap on live tokens is the account's, not the issuer's: an administrator
369
+ minting for someone cannot fill the table past what that person could fill
370
+ it to themselves."""
371
+ from .tokens import EXPIRY_CHOICES, MAX_LABEL, MAX_LIVE_TOKENS, PERMISSIONS
372
+ user_id = _uuid(user_id)
373
+ label = (label or "").strip()
374
+ if len(label) > MAX_LABEL:
375
+ raise Refused(422, f"the label is longer than {MAX_LABEL} characters")
376
+ if permission not in PERMISSIONS:
377
+ raise Refused(422, "access must be read or write")
378
+ if expires_days not in EXPIRY_CHOICES:
379
+ raise Refused(422, f"expiry must be one of {', '.join(map(str, EXPIRY_CHOICES))} days")
380
+ if namespace_id:
381
+ namespace_id = _uuid(namespace_id)
382
+ if identity.reaches(conn, user_id, namespace_id) is None:
383
+ raise Refused(422, "that person cannot open this space — add them to it first")
384
+ with conn.cursor() as cur:
385
+ cur.execute("SELECT 1 FROM app_user WHERE id = %s", (user_id,))
386
+ if cur.fetchone() is None:
387
+ raise Refused(404, "not found")
388
+ cur.execute("SELECT count(*) FROM token WHERE user_id = %s AND revoked_at IS NULL "
389
+ "AND (expires_at IS NULL OR expires_at > now())", (user_id,))
390
+ if cur.fetchone()[0] >= MAX_LIVE_TOKENS:
391
+ raise Refused(409, f"that account already has {MAX_LIVE_TOKENS} active tokens — "
392
+ "revoke some it no longer uses")
393
+ out = _guard(lambda: admin.issue_token(conn, p, user_id=user_id, namespace_id=namespace_id or None,
394
+ permission=permission, label=label, expires_days=expires_days,
395
+ defer_delivery=True))
396
+ return {"id": out["id"], "token": out["secret"], "permission": permission,
397
+ "namespace_id": namespace_id or None, "expires_days": expires_days}
398
+
399
+
278
400
  # ─── record history (for authors) ────────────────────────────────────────────
279
401
  def record_history(store, principal, space_id: str, record_id: str) -> list:
280
402
  """Who changed a record and when — the way into a person's profile from
@@ -324,6 +446,26 @@ def mount(app, cfg, pool, panel, providers, make_store) -> None:
324
446
  _run(lambda conn: edit(conn, p, user_id, changes))
325
447
  return {"user_id": user_id}
326
448
 
449
+ @app.post("/ui/api/admin/people", status_code=201)
450
+ def new_person(request: Request, fields: dict = Body(...)):
451
+ p = control_principal(_admin(request, changing=True))
452
+ return {"id": _run(lambda conn: create_person(conn, p, fields))}
453
+
454
+ @app.post("/ui/api/admin/people/{user_id}/can-create-spaces")
455
+ def can_create(user_id: str, request: Request, allowed: bool = Body(..., embed=True)):
456
+ p = control_principal(_admin(request, changing=True))
457
+ _run(lambda conn: set_can_create_spaces(conn, p, user_id, allowed))
458
+ return {"user_id": user_id, "can_create_namespace": bool(allowed)}
459
+
460
+ @app.post("/ui/api/admin/people/{user_id}/tokens", status_code=201)
461
+ def token_for(user_id: str, request: Request, label: str = Body("", embed=True),
462
+ permission: str = Body("write", embed=True),
463
+ namespace_id: Optional[str] = Body(None, embed=True),
464
+ expires_days: int = Body(90, embed=True)):
465
+ p = control_principal(_admin(request, changing=True))
466
+ return _run(lambda conn: issue_for(conn, p, user_id, label=label, permission=permission,
467
+ namespace_id=namespace_id, expires_days=expires_days))
468
+
327
469
  @app.post("/ui/api/admin/people/{user_id}/role")
328
470
  def set_role(user_id: str, request: Request, role: str = Body(..., embed=True)):
329
471
  p = control_principal(_admin(request, changing=True))
@@ -149,8 +149,11 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
149
149
 
150
150
  def _session_view(s) -> dict:
151
151
  pending = 0
152
+ can_create = False
152
153
  with pool.connection() as conn:
153
154
  me = sessions.profile(conn, s.user_id)
155
+ if s.user_id is not None:
156
+ can_create = identity.can_create_namespace(conn, sessions.control_principal(s))
154
157
  if s.role in identity.ADMIN_ROLES:
155
158
  with conn.cursor() as cur:
156
159
  cur.execute("SELECT count(*) FROM signin_request WHERE status = 'pending'")
@@ -168,8 +171,12 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
168
171
  "memory": s.user_id is not None,
169
172
  "tokens": s.user_id is not None,
170
173
  "admin": s.role in identity.ADMIN_ROLES,
174
+ # the right to make a space of one's own, as the control plane reads it
175
+ "create_space": s.user_id is not None and can_create,
171
176
  },
172
177
  "locales": list(LOCALES),
178
+ # where agents connect, for the token dialogs (the panel cannot work it out)
179
+ "mcp_url": cfg.mcp_public_url or None,
173
180
  }
174
181
 
175
182
  def _client_key(request: Request) -> str:
@@ -7,7 +7,9 @@ second copy of them:
7
7
  * members, invitations and requests of a space — its administrators (the owner,
8
8
  an ``admin`` member, a superadmin);
9
9
  * handing the space to someone else — the owner or a superadmin;
10
- * asking to join, or leaving — the person themselves.
10
+ * asking to join, or leaving — the person themselves;
11
+ * making a space of your own — anyone the deployment granted that right
12
+ (``can_create_namespace``, a switch on the person's page).
11
13
 
12
14
  **An invitation opens a space, never the server.** Inviting an address that
13
15
  already has an account adds that account at once. Otherwise the invitation waits
@@ -265,6 +267,29 @@ def transfer(conn, p, space_id: str, user_id: str, *, keep_me: bool) -> dict:
265
267
  raise Refused(409, str(e)) from None
266
268
 
267
269
 
270
+ def create_own(conn, p, *, name: str, description: str) -> dict:
271
+ """A space of one's own. The right is the control plane's
272
+ (``can_create_namespace``); everything else — the name's shape, the cap on
273
+ how many one account may own — is enforced where namespaces are created."""
274
+ name = (name or "").strip()
275
+ if not name:
276
+ raise Refused(422, "a space needs a name")
277
+ if len(name) > 100:
278
+ raise Refused(422, "the name is longer than 100 characters")
279
+ description = (description or "").strip()[:500]
280
+ if p.user_id is None:
281
+ raise Refused(409, "the administrator token has no account to own a space")
282
+ try:
283
+ nsid = identity.create_own_namespace(conn, p, name, description=description)
284
+ except identity.SpaceAmbiguous as e: # a name you already own, or the cap
285
+ raise Refused(409, str(e)) from None
286
+ except identity.AuthError as e:
287
+ raise Refused(403, str(e)) from None
288
+ except ValueError as e:
289
+ raise Refused(422, str(e)) from None
290
+ return {"id": nsid, "name": name}
291
+
292
+
268
293
  # ─── asking to join ──────────────────────────────────────────────────────────
269
294
  def my_request(conn, user_id: str, space_id: str) -> dict:
270
295
  """The caller's own request for a space, if any — answered from the caller's
@@ -399,6 +424,12 @@ def mount(app, cfg, pool, panel) -> None:
399
424
  except Refused as e:
400
425
  raise HTTPException(e.status, str(e))
401
426
 
427
+ @app.post("/ui/api/spaces", status_code=201)
428
+ def make_space(request: Request, name: str = Body(..., embed=True),
429
+ description: str = Body("", embed=True)):
430
+ s = panel["changing"](request)
431
+ return _run(lambda conn: create_own(conn, control_principal(s), name=name, description=description))
432
+
402
433
  @app.get("/ui/api/spaces/{space_id}/members")
403
434
  def members(space_id: str, request: Request):
404
435
  p = control_principal(panel["session"](request))
@@ -489,3 +489,24 @@ a.person:hover { color: var(--focus); box-shadow: inset 0 -1px 0 var(--focus); }
489
489
  @media (prefers-reduced-motion: reduce) { .spinner, .search.busy::before { animation-duration: 2.4s; } }
490
490
  /* the results header — the query and × — stays put while the list scrolls under it */
491
491
  .results > .res-head { position: sticky; top: -14px; z-index: 2; margin: -14px -14px 0; padding: 14px 14px 10px; background: var(--panel); border-radius: 14px 14px 0 0; box-shadow: 0 1px 0 var(--line-soft); }
492
+
493
+ /* people directory pages, recent edits, switches, connection how-to */
494
+ .pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
495
+ .pager .note { margin-right: auto; }
496
+ .recent { display: grid; gap: 2px; }
497
+ .rrow { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 7px 8px; border-radius: 8px; text-decoration: none; color: var(--text); }
498
+ .rrow:hover { background: var(--panel-2); }
499
+ .rrow .op { font: 500 11px var(--mono); color: var(--muted); }
500
+ .rrow .rt { min-width: 0; }
501
+ .rrow .rt b { display: block; font-weight: 500; color: var(--bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
502
+ .rrow .rt small { display: flex; align-items: center; gap: 6px; font: 11.5px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
503
+ .rrow time { font-size: 12px; color: var(--muted); white-space: nowrap; }
504
+ .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
505
+ .switch input { width: 16px; height: 16px; accent-color: var(--admin); }
506
+ .howto .steps { margin: 0; padding-left: 1.3em; display: grid; gap: 10px; }
507
+ .howto .steps li { line-height: 1.55; }
508
+ .howto .snips { margin: 8px 0 6px; }
509
+ .howto pre.code { margin-top: 6px; }
510
+ @media (max-width: 760px) { .rrow { grid-template-columns: minmax(0, 1fr) auto; } .rrow .op { display: none; } }
511
+ .every-link.make { color: var(--focus); text-decoration-color: rgba(79,214,242,.4); }
512
+ .every-link.make:hover { text-decoration-color: var(--focus); }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { get, patch } from "./api.js";
4
4
  import { fillLanguageSelect, t } from "./i18n.js";
5
- import { activityCard, spacesCard } from "./people.js";
5
+ import { activityCard, recentCard, spacesCard } from "./people.js";
6
6
  import { $, esc, toast } from "./ui.js";
7
7
 
8
8
  export function renderAccount(root, ctx) {
@@ -35,7 +35,7 @@ export function renderAccount(root, ctx) {
35
35
  <div class="pane" id="acc-more"></div>`;
36
36
  get(`/people/${encodeURIComponent(me.id)}`).then((data) => {
37
37
  const more = $("#acc-more", box);
38
- if (more) more.innerHTML = activityCard(data.activity, { note: t("act.noteSelf") }) + spacesCard(data.spaces);
38
+ if (more) more.innerHTML = activityCard(data.activity, { note: t("act.noteSelf") }) + recentCard(data.recent, { note: t("recent.noteSelf") }) + spacesCard(data.spaces);
39
39
  }).catch(() => {});
40
40
  const select = $("#acc-lang", box);
41
41
  fillLanguageSelect(select, me.ui_language || "auto");
@@ -93,29 +93,30 @@ async function renderRequests(box, ctx) {
93
93
  }
94
94
 
95
95
  // ─── the directory ───────────────────────────────────────────────────────────
96
+ const PAGE = 25;
97
+
96
98
  async function renderPeople(box) {
97
- box.innerHTML = `<div class="card"><div class="card-h"><h3>${esc(t("adm.people"))}</h3><span class="note" id="pp-total"></span></div>
99
+ box.innerHTML = `<div class="card"><div class="card-h"><h3>${esc(t("adm.people"))}</h3>
100
+ <button class="btn adminbtn small" id="pp-new">${esc(t("adm.newPerson"))}</button></div>
98
101
  <div class="filter" style="max-width:none;margin-bottom:12px"><svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.6" fill="none" stroke="#707aa0" stroke-width="1.6"/><path d="m10.4 10.4 3.4 3.4" stroke="#707aa0" stroke-width="1.6" stroke-linecap="round"/></svg>
99
102
  <input id="pp-q" type="search" autocomplete="off" placeholder="${esc(t("people.searchPh"))}" aria-label="${esc(t("people.searchPh"))}"></div>
100
103
  <div class="x"><table id="pp-table"><tbody><tr><td class="note">…</td></tr></tbody></table></div>
101
- <div class="row" id="pp-more" style="justify-content:center;margin-top:10px" hidden><button class="btn small">${esc(t("viz.showMore"))}</button></div>
104
+ <div class="pager" id="pp-pager"></div>
102
105
  </div>
103
106
  <p class="note">${esc(t("adm.peopleNote"))}</p>`;
104
- const PAGE = 50;
105
- let rows = [], seq = 0, timer = null;
106
- const load = async (append = false) => {
107
+ let offset = 0, total = 0, seq = 0, timer = null;
108
+ const load = async () => {
107
109
  const my = ++seq, q = $("#pp-q", box).value.trim();
108
110
  let got;
109
111
  try {
110
- got = await get(`/admin/people?limit=${PAGE}&offset=${append ? rows.length : 0}&q=${encodeURIComponent(q)}`);
112
+ got = await get(`/admin/people?limit=${PAGE}&offset=${offset}&q=${encodeURIComponent(q)}`);
111
113
  } catch {
112
114
  if (my === seq) $("#pp-table", box).innerHTML = `<tbody><tr><td class="note">${esc(t("err.network"))}</td></tr></tbody>`;
113
115
  return;
114
116
  }
115
117
  if (my !== seq) return;
116
- rows = append ? rows.concat(got.people) : got.people;
117
- $("#pp-total", box).textContent = t("adm.peopleCount", { n: got.total });
118
- $("#pp-more", box).hidden = rows.length >= got.total;
118
+ total = got.total;
119
+ const rows = got.people;
119
120
  $("#pp-table", box).innerHTML = rows.length ? `<thead><tr>${["adm.col.person", "acc.role", "adm.col.signins", "adm.col.lastSignin", "adm.col.lastWrite", "tok.status"].map((k) => `<th>${esc(t(k))}</th>`).join("")}</tr></thead>
120
121
  <tbody>${rows.map((p) => `<tr>
121
122
  <td class="strong">${personLink(p.id, personName(p))}<small class="cell-sub">${esc([p.email, p.department].filter(Boolean).join(" · "))}</small></td>
@@ -125,8 +126,55 @@ async function renderPeople(box) {
125
126
  <td>${esc(p.last_write_at ? ago(p.last_write_at) : t("tok.never"))}</td>
126
127
  <td><span class="dot ${p.disabled ? "off" : "on"}"><i></i>${esc(t(p.disabled ? "people.disabled" : "people.active"))}</span></td>
127
128
  </tr>`).join("")}</tbody>` : `<tbody><tr><td class="note">${esc(t("people.noneFound"))}</td></tr></tbody>`;
129
+ const from = total ? offset + 1 : 0, to = offset + rows.length;
130
+ $("#pp-pager", box).innerHTML = `<span class="note">${esc(t("adm.range", { from: nf(from), to: nf(to), total: nf(total) }))}</span>
131
+ <button class="btn small quiet" data-page="-1" ${offset === 0 ? "disabled" : ""} aria-label="${esc(t("adm.prev"))}">‹ ${esc(t("adm.prev"))}</button>
132
+ <button class="btn small quiet" data-page="1" ${to >= total ? "disabled" : ""} aria-label="${esc(t("adm.next"))}">${esc(t("adm.next"))} ›</button>`;
128
133
  };
129
- $("#pp-q", box).addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => load(), 220); });
130
- $("#pp-more button", box).onclick = () => load(true);
134
+ $("#pp-q", box).addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { offset = 0; load(); }, 220); });
135
+ $("#pp-pager", box).addEventListener("click", (e) => {
136
+ const b = e.target.closest("[data-page]");
137
+ if (!b || b.disabled) return;
138
+ offset = Math.max(0, offset + Number(b.dataset.page) * PAGE);
139
+ load();
140
+ });
141
+ $("#pp-new", box).onclick = () => newPersonDialog();
131
142
  await load();
132
143
  }
144
+
145
+ function newPersonDialog() {
146
+ const veil = document.createElement("div");
147
+ veil.className = "veil";
148
+ const field = (k, label, type = "text") => `<div class="field"><label for="np-${k}">${esc(label)}</label><input id="np-${k}" type="${type}" maxlength="200"></div>`;
149
+ veil.innerHTML = `<form class="dialog" role="dialog" aria-modal="true" aria-labelledby="np-title">
150
+ <h3 id="np-title">${esc(t("adm.newPerson"))}</h3>
151
+ ${field("full_name", t("acc.name"))}
152
+ ${field("email", t("acc.email"), "email")}
153
+ <div class="two">${field("department", t("people.department"))}${field("position", t("people.position"))}</div>
154
+ <p class="note">${esc(t("adm.newPersonNote"))}</p>
155
+ <p class="err" id="np-err" role="alert" hidden></p>
156
+ <div class="row" style="justify-content:flex-end"><button type="button" class="btn quiet" data-x>${esc(t("common.cancel"))}</button>
157
+ <button type="submit" class="btn adminbtn">${esc(t("adm.create"))}</button></div>
158
+ </form>`;
159
+ document.body.append(veil);
160
+ const close = () => veil.remove();
161
+ veil.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
162
+ veil.addEventListener("click", (e) => { if (e.target === veil || e.target.closest("[data-x]")) close(); });
163
+ $("#np-full_name", veil).focus();
164
+ $("form", veil).addEventListener("submit", async (e) => {
165
+ e.preventDefault();
166
+ const body = {};
167
+ for (const k of ["full_name", "email", "department", "position"]) body[k] = $("#np-" + k, veil).value.trim();
168
+ try {
169
+ const { id } = await post("/admin/people", body);
170
+ close();
171
+ toast(t("adm.created", { name: body.full_name || body.email }));
172
+ history.pushState(null, "", `/people?id=${encodeURIComponent(id)}`);
173
+ window.memgresPanel?.render?.();
174
+ } catch (ex) {
175
+ const err = $("#np-err", veil);
176
+ err.textContent = ex instanceof ApiError && ex.detail ? String(ex.detail) : t("err.save");
177
+ err.hidden = false;
178
+ }
179
+ });
180
+ }
@@ -34,6 +34,9 @@ export async function renderMemory(root, ctx) {
34
34
  if (!target) { showEmpty(); return; }
35
35
  if (!m.space || m.space.id !== target.id || !m.graph) await openSpace(target);
36
36
  else { mountViz(); inspect(); }
37
+ // a link to one record (from a person's recent edits): open it where it is
38
+ const rec = new URLSearchParams(location.search).get("record");
39
+ if (rec && m.byRecord.has(rec)) select(m.byRecord.get(rec), { centre: true });
37
40
  }
38
41
 
39
42
  // The sidebar's list of spaces, on every page — not only while memory is open.
@@ -95,6 +98,46 @@ function forget(id) {
95
98
  else renderSpaceList();
96
99
  }
97
100
 
101
+ // A space of your own — for whoever the deployment granted that right.
102
+ function makeSpaceDialog() {
103
+ const veil = document.createElement("div");
104
+ veil.className = "veil";
105
+ veil.innerHTML = `<form class="dialog" role="dialog" aria-modal="true" aria-labelledby="ms-title">
106
+ <h3 id="ms-title">${esc(t("sp.make"))}</h3>
107
+ <div class="field"><label for="ms-name">${esc(t("sp.makeName"))}</label>
108
+ <input id="ms-name" maxlength="100" required placeholder="${esc(t("sp.makeNamePh"))}"></div>
109
+ <div class="field"><label for="ms-desc">${esc(t("sp.makeDesc"))}</label>
110
+ <input id="ms-desc" maxlength="500" placeholder="${esc(t("sp.makeDescPh"))}"></div>
111
+ <p class="note">${esc(t("sp.makeNote"))}</p>
112
+ <p class="err" id="ms-err" role="alert" hidden></p>
113
+ <div class="row" style="justify-content:flex-end"><button type="button" class="btn quiet" data-x>${esc(t("common.cancel"))}</button>
114
+ <button type="submit" class="btn primary">${esc(t("sp.makeGo"))}</button></div>
115
+ </form>`;
116
+ document.body.append(veil);
117
+ const close = () => veil.remove();
118
+ veil.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
119
+ veil.addEventListener("click", (e) => { if (e.target === veil || e.target.closest("[data-x]")) close(); });
120
+ $("#ms-name", veil).focus();
121
+ $("form", veil).addEventListener("submit", async (e) => {
122
+ e.preventDefault();
123
+ const submit = $("button[type=submit]", veil);
124
+ submit.disabled = true;
125
+ try {
126
+ const made = await post("/spaces", { name: $("#ms-name", veil).value.trim(), description: $("#ms-desc", veil).value.trim() });
127
+ close();
128
+ toast(t("sp.made", { name: made.name }));
129
+ m.loaded = false;
130
+ $("#shell").classList.remove("drawer");
131
+ m.navigate(`/memory?space=${encodeURIComponent(made.id)}`);
132
+ } catch (ex) {
133
+ const err = $("#ms-err", veil);
134
+ err.textContent = ex instanceof ApiError && ex.detail ? String(ex.detail) : t("err.save");
135
+ err.hidden = false;
136
+ submit.disabled = false;
137
+ }
138
+ });
139
+ }
140
+
98
141
  function openEverySpace() {
99
142
  const veil = document.createElement("div");
100
143
  veil.className = "veil";
@@ -247,6 +290,7 @@ function wireSidebar() {
247
290
  const x = e.target.closest("[data-forget]");
248
291
  if (x) { e.preventDefault(); e.stopPropagation(); forget(x.dataset.forget); return; }
249
292
  if (e.target.closest("[data-every]")) openEverySpace();
293
+ if (e.target.closest("[data-make]")) makeSpaceDialog();
250
294
  }, true);
251
295
  $("#space-filter").addEventListener("input", renderSpaceList);
252
296
  }
@@ -299,7 +343,8 @@ function renderSpaceList() {
299
343
  const role = visiting.length ? `<div class="sec-h visiting-h" title="${esc(t("every.groupWhy"))}"><span>${esc(t("every.group"))}</span></div>` + visiting.map((s) =>
300
344
  `<a class="spaceitem visiting" role="listitem" href="/memory?space=${encodeURIComponent(s.id)}" data-space="${esc(s.id)}" style="--c:${spaceColor(s)}" aria-current="${m.space?.id === s.id}" title="${esc(t("every.itemWhy", { name: s.name }))}">${hexIcon(spaceColor(s), true, 16)}<span class="nm">${esc(s.name)}</span><button class="forget" data-forget="${esc(s.id)}" aria-label="${esc(t("every.forget", { name: s.name }))}">×</button></a>`).join("") : "";
301
345
  const every = isSuper() ? `<button class="every-link" data-every>${esc(t("every.link"))}</button>` : "";
302
- $("#spacelist").innerHTML = own + role + every;
346
+ const make = m.session?.can?.create_space ? `<button class="every-link make" data-make>${esc(t("sp.make"))}</button>` : "";
347
+ $("#spacelist").innerHTML = own + role + make + every;
303
348
  if (m.space) $("#mbar-space").innerHTML = `${hexIcon(spaceColor(m.space), false, 14)}${esc(m.space.name)}`;
304
349
  }
305
350