memgres 0.13.0__tar.gz → 0.13.2__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 (167) hide show
  1. {memgres-0.13.0 → memgres-0.13.2}/PKG-INFO +1 -1
  2. {memgres-0.13.0 → memgres-0.13.2}/memgres/_version.py +1 -1
  3. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0023_relink_after_parser_fix.sql +14 -1
  4. {memgres-0.13.0 → memgres-0.13.2}/memgres/relink.py +27 -0
  5. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/admission.py +8 -2
  6. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/auth_routes.py +9 -2
  7. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/oidc.py +3 -1
  8. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/routes.py +28 -3
  9. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/app.css +60 -3
  10. memgres-0.13.2/memgres/web/static/js/gate.js +237 -0
  11. memgres-0.13.2/memgres/web/static/js/md.js +46 -0
  12. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/memory.js +103 -23
  13. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/viz.js +12 -3
  14. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/locales/en.json +4 -1
  15. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/locales/ru.json +4 -1
  16. memgres-0.13.2/memgres/web/static/vendor/LICENSE-dompurify.txt +202 -0
  17. memgres-0.13.2/memgres/web/static/vendor/LICENSE-marked.txt +44 -0
  18. memgres-0.13.2/memgres/web/static/vendor/marked.esm.js +77 -0
  19. memgres-0.13.2/memgres/web/static/vendor/purify.es.js +2721 -0
  20. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/PKG-INFO +1 -1
  21. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/SOURCES.txt +5 -0
  22. {memgres-0.13.0 → memgres-0.13.2}/tests/test_links.py +28 -0
  23. {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_oidc.py +43 -4
  24. memgres-0.13.0/memgres/web/static/js/gate.js +0 -122
  25. {memgres-0.13.0 → memgres-0.13.2}/LICENSE +0 -0
  26. {memgres-0.13.0 → memgres-0.13.2}/README.md +0 -0
  27. {memgres-0.13.0 → memgres-0.13.2}/memgres/__init__.py +0 -0
  28. {memgres-0.13.0 → memgres-0.13.2}/memgres/admin.py +0 -0
  29. {memgres-0.13.0 → memgres-0.13.2}/memgres/admin_cli.py +0 -0
  30. {memgres-0.13.0 → memgres-0.13.2}/memgres/blame.py +0 -0
  31. {memgres-0.13.0 → memgres-0.13.2}/memgres/bootstrap.py +0 -0
  32. {memgres-0.13.0 → memgres-0.13.2}/memgres/config.py +0 -0
  33. {memgres-0.13.0 → memgres-0.13.2}/memgres/delimiters.py +0 -0
  34. {memgres-0.13.0 → memgres-0.13.2}/memgres/diffing.py +0 -0
  35. {memgres-0.13.0 → memgres-0.13.2}/memgres/embed_worker.py +0 -0
  36. {memgres-0.13.0 → memgres-0.13.2}/memgres/embeddings.py +0 -0
  37. {memgres-0.13.0 → memgres-0.13.2}/memgres/healthcheck.py +0 -0
  38. {memgres-0.13.0 → memgres-0.13.2}/memgres/identity.py +0 -0
  39. {memgres-0.13.0 → memgres-0.13.2}/memgres/indexing.py +0 -0
  40. {memgres-0.13.0 → memgres-0.13.2}/memgres/info.py +0 -0
  41. {memgres-0.13.0 → memgres-0.13.2}/memgres/lines.py +0 -0
  42. {memgres-0.13.0 → memgres-0.13.2}/memgres/links.py +0 -0
  43. {memgres-0.13.0 → memgres-0.13.2}/memgres/mcp_server.py +0 -0
  44. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0001_core.sql +0 -0
  45. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0002_identity.sql +0 -0
  46. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0003_history_author.sql +0 -0
  47. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0004_title.sql +0 -0
  48. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0005_chunk_index.sql +0 -0
  49. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0006_reader_floor.sql +0 -0
  50. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0007_embed_retry.sql +0 -0
  51. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0008_service_roles.sql +0 -0
  52. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  53. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
  54. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  55. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0012_user_profile.sql +0 -0
  56. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0013_hash_version.sql +0 -0
  57. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  58. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
  59. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0016_valid_at.sql +0 -0
  60. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0017_memory_link.sql +0 -0
  61. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0018_links_built.sql +0 -0
  62. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0019_memory_usage.sql +0 -0
  63. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  64. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0021_enrollment_key.sql +0 -0
  65. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0022_user_disabled.sql +0 -0
  66. {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0024_web_panel.sql +0 -0
  67. {memgres-0.13.0 → memgres-0.13.2}/memgres/paths.py +0 -0
  68. {memgres-0.13.0 → memgres-0.13.2}/memgres/periodic.py +0 -0
  69. {memgres-0.13.0 → memgres-0.13.2}/memgres/reembed.py +0 -0
  70. {memgres-0.13.0 → memgres-0.13.2}/memgres/schema.py +0 -0
  71. {memgres-0.13.0 → memgres-0.13.2}/memgres/search.py +0 -0
  72. {memgres-0.13.0 → memgres-0.13.2}/memgres/segments.py +0 -0
  73. {memgres-0.13.0 → memgres-0.13.2}/memgres/server.py +0 -0
  74. {memgres-0.13.0 → memgres-0.13.2}/memgres/store.py +0 -0
  75. {memgres-0.13.0 → memgres-0.13.2}/memgres/tags.py +0 -0
  76. {memgres-0.13.0 → memgres-0.13.2}/memgres/token_cli.py +0 -0
  77. {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/__init__.py +0 -0
  78. {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/base.py +0 -0
  79. {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/pgvector.py +0 -0
  80. {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/qdrant.py +0 -0
  81. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/__init__.py +0 -0
  82. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/memory.py +0 -0
  83. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/oidc_config.py +0 -0
  84. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/people.py +0 -0
  85. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/sessions.py +0 -0
  86. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/spaces.py +0 -0
  87. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/OFL-JetBrainsMono.txt +0 -0
  88. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/OFL-Onest.txt +0 -0
  89. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
  90. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
  91. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  92. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  93. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
  94. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
  95. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
  96. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-latin.woff2 +0 -0
  97. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts.css +0 -0
  98. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/index.html +0 -0
  99. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/account.js +0 -0
  100. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/admin.js +0 -0
  101. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/api.js +0 -0
  102. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/app.js +0 -0
  103. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/i18n.js +0 -0
  104. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/people.js +0 -0
  105. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/signins.js +0 -0
  106. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/space.js +0 -0
  107. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/tokens.js +0 -0
  108. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/ui.js +0 -0
  109. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/vendor/LICENSE-d3.txt +0 -0
  110. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/vendor/d3.min.js +0 -0
  111. {memgres-0.13.0 → memgres-0.13.2}/memgres/web/tokens.py +0 -0
  112. {memgres-0.13.0 → memgres-0.13.2}/memgres/worker.py +0 -0
  113. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/dependency_links.txt +0 -0
  114. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/entry_points.txt +0 -0
  115. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/requires.txt +0 -0
  116. {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/top_level.txt +0 -0
  117. {memgres-0.13.0 → memgres-0.13.2}/pyproject.toml +0 -0
  118. {memgres-0.13.0 → memgres-0.13.2}/setup.cfg +0 -0
  119. {memgres-0.13.0 → memgres-0.13.2}/tests/test_admin_two_way.py +0 -0
  120. {memgres-0.13.0 → memgres-0.13.2}/tests/test_blame_integration.py +0 -0
  121. {memgres-0.13.0 → memgres-0.13.2}/tests/test_chunk_index.py +0 -0
  122. {memgres-0.13.0 → memgres-0.13.2}/tests/test_claim_and_reembed.py +0 -0
  123. {memgres-0.13.0 → memgres-0.13.2}/tests/test_config.py +0 -0
  124. {memgres-0.13.0 → memgres-0.13.2}/tests/test_diffing.py +0 -0
  125. {memgres-0.13.0 → memgres-0.13.2}/tests/test_embed_worker.py +0 -0
  126. {memgres-0.13.0 → memgres-0.13.2}/tests/test_embeddings.py +0 -0
  127. {memgres-0.13.0 → memgres-0.13.2}/tests/test_enrollment.py +0 -0
  128. {memgres-0.13.0 → memgres-0.13.2}/tests/test_healthcheck.py +0 -0
  129. {memgres-0.13.0 → memgres-0.13.2}/tests/test_identity_integration.py +0 -0
  130. {memgres-0.13.0 → memgres-0.13.2}/tests/test_lexical_match.py +0 -0
  131. {memgres-0.13.0 → memgres-0.13.2}/tests/test_limits.py +0 -0
  132. {memgres-0.13.0 → memgres-0.13.2}/tests/test_list.py +0 -0
  133. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_admin_tools.py +0 -0
  134. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_error_messages.py +0 -0
  135. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_http_transport.py +0 -0
  136. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_instructions.py +0 -0
  137. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_recall_schema.py +0 -0
  138. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_tool_visibility.py +0 -0
  139. {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_tool_visibility_http.py +0 -0
  140. {memgres-0.13.0 → memgres-0.13.2}/tests/test_migration_upgrade.py +0 -0
  141. {memgres-0.13.0 → memgres-0.13.2}/tests/test_multi_space_search.py +0 -0
  142. {memgres-0.13.0 → memgres-0.13.2}/tests/test_path_addressing.py +0 -0
  143. {memgres-0.13.0 → memgres-0.13.2}/tests/test_qdrant_ca.py +0 -0
  144. {memgres-0.13.0 → memgres-0.13.2}/tests/test_qdrant_integration.py +0 -0
  145. {memgres-0.13.0 → memgres-0.13.2}/tests/test_replace_build.py +0 -0
  146. {memgres-0.13.0 → memgres-0.13.2}/tests/test_require_title.py +0 -0
  147. {memgres-0.13.0 → memgres-0.13.2}/tests/test_required_fields.py +0 -0
  148. {memgres-0.13.0 → memgres-0.13.2}/tests/test_retention.py +0 -0
  149. {memgres-0.13.0 → memgres-0.13.2}/tests/test_roles_bootstrap.py +0 -0
  150. {memgres-0.13.0 → memgres-0.13.2}/tests/test_search_integration.py +0 -0
  151. {memgres-0.13.0 → memgres-0.13.2}/tests/test_security_followups.py +0 -0
  152. {memgres-0.13.0 → memgres-0.13.2}/tests/test_security_integration.py +0 -0
  153. {memgres-0.13.0 → memgres-0.13.2}/tests/test_segments.py +0 -0
  154. {memgres-0.13.0 → memgres-0.13.2}/tests/test_segments_store.py +0 -0
  155. {memgres-0.13.0 → memgres-0.13.2}/tests/test_server_info.py +0 -0
  156. {memgres-0.13.0 → memgres-0.13.2}/tests/test_server_integration.py +0 -0
  157. {memgres-0.13.0 → memgres-0.13.2}/tests/test_snippets.py +0 -0
  158. {memgres-0.13.0 → memgres-0.13.2}/tests/test_store_integration.py +0 -0
  159. {memgres-0.13.0 → memgres-0.13.2}/tests/test_tags.py +0 -0
  160. {memgres-0.13.0 → memgres-0.13.2}/tests/test_token_sink.py +0 -0
  161. {memgres-0.13.0 → memgres-0.13.2}/tests/test_usage.py +0 -0
  162. {memgres-0.13.0 → memgres-0.13.2}/tests/test_valid_at.py +0 -0
  163. {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_memory.py +0 -0
  164. {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_people.py +0 -0
  165. {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_session.py +0 -0
  166. {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_tokens.py +0 -0
  167. {memgres-0.13.0 → memgres-0.13.2}/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.0
3
+ Version: 0.13.2
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.0"
11
+ __version__ = "0.13.2"
@@ -13,4 +13,17 @@
13
13
  --
14
14
  -- Additive by nature — an older client reading this database is no worse off
15
15
  -- than it was, so the compatibility floor does not move.
16
- UPDATE memgres_meta SET links_built = false;
16
+ -- ONCE. Every migration file runs again at every start (they are meant to be
17
+ -- idempotent), and a bare UPDATE here cleared the flag on every start — so every
18
+ -- start rebuilt the whole link graph, and two servers starting together raced on
19
+ -- it. The marker column makes the clearing happen the first time only; on a
20
+ -- database that has been through this file before, it happens one last time.
21
+ DO $$
22
+ BEGIN
23
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
24
+ WHERE table_schema = current_schema() AND table_name = 'memgres_meta'
25
+ AND column_name = 'relinked_after_0023') THEN
26
+ ALTER TABLE memgres_meta ADD COLUMN relinked_after_0023 boolean NOT NULL DEFAULT true;
27
+ UPDATE memgres_meta SET links_built = false;
28
+ END IF;
29
+ END $$;
@@ -20,6 +20,12 @@ _log = logging.getLogger("memgres.relink")
20
20
 
21
21
  BATCH = 500
22
22
 
23
+ # One rebuild at a time across processes. Two servers starting together (the MCP
24
+ # door and the REST/panel door) both found the flag clear and rebuilt at once,
25
+ # and one of them died on the other's rows. A session-level advisory lock: held
26
+ # for the rebuild, gone with the connection if the process is.
27
+ LOCK_KEY = 0x6D656D67_6C6E6B # "memglnk"
28
+
23
29
 
24
30
  class _NoEmbedder:
25
31
  """Stands in for an embedder so `Store` does not build the real one. The
@@ -55,11 +61,32 @@ def rebuild(conn, cfg, *, force: bool = False) -> int:
55
61
  the flag false — so the server came up serving a link graph that was perfectly
56
62
  empty while saying it had built one. Owning the commit here means the work is
57
63
  durable when this returns, whatever the caller does next."""
64
+ with conn.cursor() as cur:
65
+ cur.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_KEY,))
66
+ mine = cur.fetchone()[0]
67
+ if not mine:
68
+ conn.commit()
69
+ _log.info("the link index is being rebuilt by another process; not starting a second pass")
70
+ return 0
71
+ try:
72
+ return _rebuild(conn, cfg, force=force)
73
+ finally:
74
+ try:
75
+ with conn.cursor() as cur:
76
+ cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
77
+ conn.commit()
78
+ except Exception: # a broken connection releases the lock by closing
79
+ pass
80
+
81
+
82
+ def _rebuild(conn, cfg, *, force: bool) -> int:
58
83
  from .store import Store
59
84
  with conn.cursor() as cur:
85
+ # read under the lock: another process may have just finished
60
86
  cur.execute("SELECT links_built FROM memgres_meta")
61
87
  row = cur.fetchone()
62
88
  if row and row[0] and not force:
89
+ conn.commit()
63
90
  return 0
64
91
 
65
92
  # `_sync_links` touches neither embeddings nor vectors; passing a
@@ -88,8 +88,10 @@ def _request(cur, provider: Provider, claims: dict, suggested: Optional[str]) ->
88
88
  if verified_email(claims) is None:
89
89
  # A request an administrator must judge needs at least an address the
90
90
  # provider vouches for; without one, anyone who can make accounts at an
91
- # open provider could fill the queue with nobodies.
92
- return Outcome("denied", reason="no_account")
91
+ # open provider could fill the queue with nobodies. Said as it is: the
92
+ # person can fix this at the provider, and "no account" would send them
93
+ # to the wrong place.
94
+ return Outcome("denied", reason="email_unverified")
93
95
  cur.execute("DELETE FROM signin_request WHERE status <> 'pending' "
94
96
  "AND decided_at < now() - make_interval(days => %s)", (REJECTION_MEMORY_DAYS * 3,))
95
97
  cur.execute("SELECT count(*), count(*) FILTER (WHERE provider = %s) FROM signin_request "
@@ -193,6 +195,10 @@ def admit(conn, cfg, provider: Provider, claims: dict, *, link_user_id: Optional
193
195
 
194
196
  # ─── nobody ─────────────────────────────────────────────────────────
195
197
  if provider.on_no_match == "deny":
198
+ # with a confirmed address this could have matched an account; say
199
+ # what is missing rather than that nothing exists
200
+ if email is None and provider.on_email_match != "deny":
201
+ return Outcome("denied", reason="email_unverified")
196
202
  return Outcome("denied", reason="no_account")
197
203
  if provider.on_no_match == "pending":
198
204
  return _request(cur, provider, claims, None)
@@ -57,8 +57,12 @@ def mount(app, cfg, pool, panel, providers, fetch=None) -> None:
57
57
  starts.fail(key)
58
58
  state, nonce, browser = secrets.token_urlsafe(32), secrets.token_urlsafe(32), secrets.token_urlsafe(32)
59
59
  verifier, challenge = new_pkce()
60
+ # Linking asks the provider to show its account chooser: a browser often
61
+ # holds another session there (a service admin, a colleague's), and the
62
+ # provider would otherwise hand that one back without asking.
60
63
  url = client.authorize_url(redirect_uri=_redirect_uri(request, pid), state=state,
61
- nonce=nonce, challenge=challenge)
64
+ nonce=nonce, challenge=challenge,
65
+ prompt="select_account" if link_session else None)
62
66
  with pool.connection() as conn, conn.transaction(), conn.cursor() as cur:
63
67
  cur.execute("DELETE FROM oidc_flow WHERE expires_at < now()")
64
68
  cur.execute("SELECT count(*) FROM oidc_flow")
@@ -166,7 +170,10 @@ def mount(app, cfg, pool, panel, providers, fetch=None) -> None:
166
170
  except psycopg.errors.UniqueViolation:
167
171
  # two callbacks linking the same sign-in at the same moment: one wins
168
172
  return _go(f"{back}?auth=denied_taken")
169
- log.info("oidc %s: %s%s", pid, outcome.kind, f" ({outcome.reason})" if outcome.reason else "")
173
+ # a refusal is what an administrator will be asked about: log its reason
174
+ # (never the person's email) where the default log level shows it
175
+ (log.warning if outcome.kind == "denied" else log.info)(
176
+ "oidc %s: %s%s", pid, outcome.kind, f" ({outcome.reason})" if outcome.reason else "")
170
177
  if outcome.kind == "signed_in":
171
178
  r = _go("/memory")
172
179
  panel["set_cookie"](r, sid)
@@ -126,13 +126,15 @@ class Client:
126
126
 
127
127
  # ─── the flow ───────────────────────────────────────────────────────────
128
128
  def authorize_url(self, *, redirect_uri: str, state: str, nonce: str, challenge: str,
129
- login_hint: Optional[str] = None) -> str:
129
+ login_hint: Optional[str] = None, prompt: Optional[str] = None) -> str:
130
130
  meta = self.metadata()
131
131
  q = {"response_type": "code", "client_id": self.p.client_id, "redirect_uri": redirect_uri,
132
132
  "scope": " ".join(self.p.scopes), "state": state, "nonce": nonce,
133
133
  "code_challenge": challenge, "code_challenge_method": "S256"}
134
134
  if login_hint:
135
135
  q["login_hint"] = login_hint
136
+ if prompt:
137
+ q["prompt"] = prompt
136
138
  sep = "&" if "?" in meta["authorization_endpoint"] else "?"
137
139
  return meta["authorization_endpoint"] + sep + urllib.parse.urlencode(q)
138
140
 
@@ -12,6 +12,7 @@ Two rules keep a browser session from widening what an attacker can reach:
12
12
  ``SameSite=Strict`` as well; that is the second lock, not the only one.
13
13
  """
14
14
 
15
+ import logging
15
16
  import threading
16
17
  import time
17
18
  from pathlib import Path
@@ -75,6 +76,27 @@ class _Throttle:
75
76
  self._fails.pop(key, None)
76
77
 
77
78
 
79
+ class _ScrubSignInQuery(logging.Filter):
80
+ """Keep the query string of /ui/auth/* out of the access log.
81
+
82
+ The provider's redirect back carries a one-time code and the state. Both are
83
+ spent by the time the line is written and useless without the PKCE verifier,
84
+ but a log is kept and copied, and there is no reason for them to be in it."""
85
+
86
+ def filter(self, record: logging.LogRecord) -> bool:
87
+ args = record.args
88
+ if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str) \
89
+ and args[2].startswith("/ui/auth/") and "?" in args[2]:
90
+ record.args = args[:2] + (args[2].split("?", 1)[0] + "?…",) + args[3:]
91
+ return True
92
+
93
+
94
+ def _scrub_access_log() -> None:
95
+ access = logging.getLogger("uvicorn.access")
96
+ if not any(isinstance(f, _ScrubSignInQuery) for f in access.filters):
97
+ access.addFilter(_ScrubSignInQuery())
98
+
99
+
78
100
  def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
79
101
  from fastapi import Body, HTTPException, Request, Response
80
102
  from fastapi.responses import FileResponse, RedirectResponse
@@ -83,6 +105,7 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
83
105
  from . import oidc_config
84
106
  cookie_name = "__Host-memgres_session" if cfg.web_cookie_secure else "memgres_session"
85
107
  throttle = _Throttle()
108
+ _scrub_access_log()
86
109
  # read at startup: a broken provider file stops the server rather than
87
110
  # leaving a sign-in door that half-works
88
111
  providers = oidc_config.load(cfg.oidc_config, key_mode=cfg.key_mode)
@@ -172,17 +195,19 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
172
195
  return FileResponse(shell, media_type="text/html",
173
196
  headers={"Cache-Control": "no-cache"})
174
197
 
198
+ # HEAD too: a page answers HEAD as it answers GET (RFC 9110), and uptime
199
+ # checks use it — a 405 there reads as "the panel is down"
175
200
  for p in APP_PATHS:
176
- app.add_api_route(p, _page, methods=["GET"], include_in_schema=False)
201
+ app.add_api_route(p, _page, methods=["GET", "HEAD"], include_in_schema=False)
177
202
 
178
- @app.get("/signin", include_in_schema=False)
203
+ @app.api_route("/signin", methods=["GET", "HEAD"], include_in_schema=False)
179
204
  def signin_page():
180
205
  with pool.connection() as conn:
181
206
  if not sessions.admin_has_linked_signin(conn):
182
207
  return RedirectResponse("/signin/admin?from=signin", status_code=307)
183
208
  return _page()
184
209
 
185
- @app.get("/signin/admin", include_in_schema=False)
210
+ @app.api_route("/signin/admin", methods=["GET", "HEAD"], include_in_schema=False)
186
211
  def signin_admin_page():
187
212
  return _page()
188
213
 
@@ -96,7 +96,8 @@
96
96
  .search input::placeholder { color: var(--muted); }
97
97
  .search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); }
98
98
 
99
- .workspace { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 10px; min-height: 0; }
99
+ .workspace { display: grid; grid-template-columns: minmax(0, 1fr) 12px var(--insp-w, 360px); gap: 0; min-height: 0; }
100
+ .workspace.with-results { grid-template-columns: minmax(0, 1fr) 310px 12px var(--insp-w, 360px); }
100
101
  .stage { position: relative; min-height: 0; border-radius: 14px; overflow: hidden; background: radial-gradient(1200px 800px at 50% 50%, #182038 0%, var(--canvas) 55%, #0f1424 100%); box-shadow: inset 0 0 0 1px var(--line-soft); }
101
102
  .viz-host { position: absolute; inset: 0; }
102
103
  .stagebar { position: absolute; left: 10px; right: 10px; top: 10px; z-index: 4; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; pointer-events: none; }
@@ -304,10 +305,15 @@
304
305
  .snips button[aria-selected="true"] { color: var(--bright); background: var(--panel-2); }
305
306
  .toast { position: fixed; left: 50%; bottom: calc(22px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; background: var(--panel-2); color: var(--bright); padding: 9px 14px; border-radius: 10px; box-shadow: 0 0 0 1px var(--line), 0 12px 30px rgba(0,0,0,.45); font-size: 13px; max-width: calc(100% - 32px); box-sizing: border-box; }
306
307
 
307
- @media (max-width: 1100px) { .workspace { grid-template-columns: minmax(0, 1fr) 320px; } }
308
+ @media (max-width: 1100px) {
309
+ .workspace { grid-template-columns: minmax(0, 1fr) 12px var(--insp-w, 320px); }
310
+ .workspace.with-results { grid-template-columns: minmax(0, 1fr) 280px 12px var(--insp-w, 320px); }
311
+ }
308
312
  @media (max-width: 960px) {
309
313
  body { overflow: auto; }
310
- .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(380px, 1fr) auto; }
314
+ .workspace, .workspace.with-results { grid-template-columns: 1fr; grid-template-rows: minmax(380px, 1fr) auto auto; gap: 10px; }
315
+ .splitter { display: none; }
316
+ .results { margin-left: 0 !important; max-height: 50vh; }
311
317
  .inspector { max-height: none; }
312
318
  .gate { grid-template-columns: 1fr; grid-template-rows: 200px auto; height: auto; min-height: 100%; }
313
319
  .word { font-size: 32px; }
@@ -432,3 +438,54 @@ a.person:hover { color: var(--focus); box-shadow: inset 0 -1px 0 var(--focus); }
432
438
  .perm-badge.role { color: var(--admin); box-shadow: inset 0 0 0 1px rgba(255,162,76,.45); }
433
439
  .every-list { max-height: min(60vh, 520px); overflow: auto; }
434
440
  .shell.collapsed .visiting-h, .shell.collapsed .every-link, .shell.collapsed .spaceitem .forget { display: none; }
441
+
442
+ /* the sign-in picture can be pulled about; the words over it stay out of the way */
443
+ .gate-art canvas { touch-action: none; }
444
+ .gate-art .words { pointer-events: none; }
445
+
446
+ /* ─── search results column, the record panel's edge, markdown bodies ─── */
447
+ .results { margin-left: 10px; min-height: 0; overflow: auto; border-radius: 14px; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line-soft); padding: 14px; display: flex; flex-direction: column; gap: 10px; }
448
+ .res-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
449
+ .res-head b { display: block; color: var(--bright); font-weight: 600; font-size: 14px; margin-top: 4px; overflow-wrap: anywhere; }
450
+ .res-wait { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; padding: 6px 2px; }
451
+ .list.stale { opacity: .5; transition: opacity .15s; }
452
+ .list button[aria-current="true"] { background: var(--panel-2); box-shadow: inset 0 0 0 1px var(--line); }
453
+ .spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--focus); animation: spin .8s linear infinite; flex: none; box-sizing: border-box; }
454
+ @keyframes spin { to { transform: rotate(360deg); } }
455
+ .search.busy svg { visibility: hidden; }
456
+ .search.busy::before { content: ""; position: absolute; left: 10px; top: 50%; width: 14px; height: 14px; margin-top: -7px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--focus); animation: spin .8s linear infinite; box-sizing: border-box; }
457
+
458
+ .splitter { cursor: col-resize; position: relative; touch-action: none; outline: none; }
459
+ .splitter::after { content: ""; position: absolute; top: 18%; bottom: 18%; left: 50%; width: 2px; margin-left: -1px; border-radius: 2px; background: transparent; transition: background .15s; }
460
+ .splitter:hover::after, .splitter.on::after, .splitter:focus-visible::after { background: var(--focus); }
461
+
462
+ .body.md { white-space: normal; overflow-wrap: anywhere; }
463
+ .body.md > :first-child { margin-top: 0; }
464
+ .body.md > :last-child { margin-bottom: 0; }
465
+ .body.md p { margin: .55em 0; }
466
+ .body.md h1, .body.md h2, .body.md h3, .body.md h4, .body.md h5, .body.md h6 { color: var(--bright); line-height: 1.3; margin: 1.1em 0 .45em; font-weight: 600; }
467
+ .body.md h1 { font-size: 1.25em; } .body.md h2 { font-size: 1.15em; } .body.md h3 { font-size: 1.05em; } .body.md h4, .body.md h5, .body.md h6 { font-size: 1em; }
468
+ .body.md ul, .body.md ol { margin: .5em 0; padding-left: 1.35em; }
469
+ .body.md li { margin: .2em 0; }
470
+ .body.md li > p { margin: .2em 0; }
471
+ .body.md strong { color: var(--bright); font-weight: 600; }
472
+ .body.md a { color: var(--focus); text-underline-offset: 3px; }
473
+ .body.md blockquote { margin: .6em 0; padding: .1em 0 .1em 12px; border-left: 3px solid var(--line); color: var(--muted); }
474
+ .body.md hr { border: 0; border-top: 1px solid var(--line); margin: 1em 0; }
475
+ .body.md pre { background: var(--canvas); border-radius: 8px; padding: 10px 12px; overflow-x: auto; margin: .6em 0; }
476
+ .body.md pre code { background: none; padding: 0; font-size: 12px; line-height: 1.55; white-space: pre; }
477
+ .body.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; margin: .7em 0; font-size: 12.5px; width: max-content; }
478
+ .body.md th, .body.md td { border: 1px solid var(--line); padding: 5px 9px; white-space: normal; vertical-align: top; text-align: left; min-width: 60px; }
479
+ .body.md th { background: var(--canvas); color: var(--bright); font-weight: 600; font-size: 12.5px; }
480
+ .body.md tr:last-child td { border-bottom: 1px solid var(--line); }
481
+ .body.md input[type=checkbox] { margin-right: 6px; }
482
+ .body.md .md-img { color: var(--muted); font-style: italic; }
483
+
484
+ /* the space's own node */
485
+ .node.hubnode .hub-ring { fill: none; stroke-width: 1.4; opacity: .5; stroke-linejoin: round; }
486
+ .node.hubnode .ring.hub { stroke-width: 3.2; fill-opacity: .22; }
487
+ .node.hubnode .halo { opacity: .12; stroke-width: 18; }
488
+ .node.hubnode.sel .halo { opacity: .22; }
489
+ @media (prefers-reduced-motion: reduce) { .spinner, .search.busy::before { animation-duration: 2.4s; } }
490
+ /* the results header — the query and × — stays put while the list scrolls under it */
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); }
@@ -0,0 +1,237 @@
1
+ // /signin (providers) and /signin/admin (a token, for administrators).
2
+
3
+ import { post, ApiError } from "./api.js";
4
+ import { applyStatic, t } from "./i18n.js";
5
+ import { $, esc, hexPath, PALETTE, reducedMotion, rnd } from "./ui.js";
6
+
7
+ let raf = null;
8
+
9
+ export function showGate({ path, options, onSignedIn }) {
10
+ $("#shell").hidden = true;
11
+ $("#gate").hidden = false;
12
+ const admin = path.startsWith("/signin/admin");
13
+ $("#door-main").hidden = admin;
14
+ $("#door-admin").hidden = !admin;
15
+ if (admin) {
16
+ const redirected = new URLSearchParams(location.search).get("from") === "signin" || options.admin_redirect;
17
+ $("#redirected").hidden = !redirected;
18
+ $("#admin-back").hidden = redirected;
19
+ $("#admin-err").hidden = true;
20
+ const form = $("#door-admin");
21
+ form.onsubmit = async (e) => {
22
+ e.preventDefault();
23
+ const input = $("#admin-token"), err = $("#admin-err"), btn = $("#admin-go");
24
+ const token = input.value.trim();
25
+ if (!token) { err.textContent = t("gate.admin.empty"); err.hidden = false; return; }
26
+ btn.disabled = true;
27
+ try {
28
+ const session = await post("/session/token", { token });
29
+ input.value = "";
30
+ stopArt();
31
+ onSignedIn(session);
32
+ } catch (ex) {
33
+ err.textContent = ex instanceof ApiError && ex.status === 429 ? t("gate.admin.throttled")
34
+ : ex instanceof ApiError && ex.status === 403 ? t("gate.admin.refused") : t("err.network");
35
+ err.hidden = false;
36
+ } finally {
37
+ btn.disabled = false;
38
+ }
39
+ };
40
+ setTimeout(() => $("#admin-token").focus(), 0);
41
+ } else {
42
+ renderProviders(options.providers || []);
43
+ const result = new URLSearchParams(location.search).get("auth");
44
+ const note = $("#auth-note");
45
+ note.hidden = !result;
46
+ if (result) {
47
+ note.className = "auth-note " + (result === "pending" ? "wait" : "bad");
48
+ note.textContent = t("auth." + result) === "auth." + result ? t("auth.failed") : t("auth." + result);
49
+ }
50
+ }
51
+ applyStatic($("#gate"));
52
+ startArt();
53
+ }
54
+
55
+ function renderProviders(providers) {
56
+ const box = $("#providers");
57
+ if (!providers.length) {
58
+ box.innerHTML = `<div class="empty-prov">${esc(t("gate.noProviders"))}</div>`;
59
+ return;
60
+ }
61
+ box.innerHTML = providers.map((p) => `<a class="prov-btn" href="/ui/auth/${encodeURIComponent(p.id)}/start">
62
+ <span class="prov">${esc((p.label || p.id).slice(0, 3))}</span>
63
+ <span><b>${esc(t("gate.continueWith", { name: p.label || p.id }))}</b>${p.hint ? `<small>${esc(p.hint)}</small>` : ""}</span></a>`).join("");
64
+ }
65
+
66
+ export function hideGate() {
67
+ stopArt();
68
+ $("#gate").hidden = true;
69
+ }
70
+
71
+ // ─── the picture on the left: a memory you can pull about ──────────────────
72
+ // A small tree like a real space — a root, a handful of branches, records under
73
+ // them, a few links across — laid out by d3-force. Any hexagon but the root can be
74
+ // dragged, and its neighbours follow on their links. The layout is settled before
75
+ // the first frame, so it never opens as a tangle.
76
+ let art = null;
77
+
78
+ function buildGraph() {
79
+ const nodes = [{ id: 0, r: 34, c: "#62e38d", depth: 0, fx: 0, fy: 0 }];
80
+ const links = [];
81
+ const add = (parent, depth, r, c, bare) => {
82
+ const n = { id: nodes.length, r, c, depth, bare, ph: rnd("ph" + nodes.length) * 6.28 };
83
+ nodes.push(n);
84
+ links.push({ source: parent.id, target: n.id, depth });
85
+ return n;
86
+ };
87
+ const hubs = 9;
88
+ for (let h = 0; h < hubs; h++) {
89
+ const c = PALETTE[h % PALETTE.length];
90
+ const hub = add(nodes[0], 1, 13 + rnd("hr" + h) * 7, c, h % 4 === 3);
91
+ const kids = 4 + Math.floor(rnd("hk" + h) * 5);
92
+ for (let k = 0; k < kids; k++) {
93
+ const key = `${h}.${k}`;
94
+ const child = add(hub, 2, 6 + rnd("cr" + key) ** 2 * 8, c, rnd("cb" + key) < 0.14);
95
+ if (rnd("cg" + key) < 0.32) {
96
+ const grand = 1 + Math.floor(rnd("gn" + key) * 3);
97
+ for (let g = 0; g < grand; g++) add(child, 3, 4.5 + rnd("gr" + key + g) * 3.5, c, false);
98
+ }
99
+ }
100
+ }
101
+ // a few links across branches, the way records cite each other
102
+ const leaves = nodes.filter((n) => n.depth >= 2);
103
+ for (let x = 0; x < 12; x++) {
104
+ const a = leaves[Math.floor(rnd("xa" + x) * leaves.length)], b = leaves[Math.floor(rnd("xb" + x) * leaves.length)];
105
+ if (a !== b && a.c !== b.c) links.push({ source: a.id, target: b.id, depth: 9, cross: true });
106
+ }
107
+ return { nodes, links };
108
+ }
109
+
110
+ function startArt() {
111
+ stopArt();
112
+ const d3 = window.d3, cv = $("#gate-canvas");
113
+ if (!d3 || !cv) return;
114
+ const ctx = cv.getContext("2d"), still = reducedMotion();
115
+ const { nodes, links } = buildGraph();
116
+ // The layout lives in a fixed circle and is scaled to whatever room the page
117
+ // gives it, so a phone shows the same picture smaller rather than a cropped one.
118
+ const bound = 260;
119
+ const contain = () => {
120
+ for (const n of nodes) {
121
+ if (n.fx != null && n.depth === 0) continue;
122
+ const d = Math.hypot(n.x, n.y), lim = bound - n.r;
123
+ if (d > lim) { n.x *= lim / d; n.y *= lim / d; n.vx *= 0.5; n.vy *= 0.5; }
124
+ }
125
+ };
126
+ const sim = d3.forceSimulation(nodes)
127
+ .force("link", d3.forceLink(links).id((n) => n.id)
128
+ .distance((l) => l.cross ? 150 : l.depth === 1 ? 118 : l.depth === 2 ? 40 : 24)
129
+ .strength((l) => l.cross ? 0.02 : 0.7))
130
+ .force("charge", d3.forceManyBody().strength((n) => n.depth === 0 ? -400 : -26 - n.r * 3).distanceMax(240))
131
+ .force("collide", d3.forceCollide((n) => n.r + 4).iterations(2))
132
+ .force("contain", contain)
133
+ .stop();
134
+ nodes.forEach((n, i) => { if (i) { const a = rnd("ia" + i) * 6.28, d = 60 + rnd("id" + i) * 160; n.x = Math.cos(a) * d; n.y = Math.sin(a) * d; } });
135
+ for (let i = 0; i < 320; i++) sim.tick();
136
+ sim.alphaTarget(0).alpha(0);
137
+ sim.on("tick", () => {}); // ticks run on d3's own timer only while dragging
138
+
139
+ const view = { cx: 0, cy: 0, dpr: 1, k: 1 };
140
+ const toGraph = (e) => {
141
+ const r = cv.getBoundingClientRect();
142
+ return [(e.clientX - r.left - view.cx) / view.k, (e.clientY - r.top - view.cy) / view.k];
143
+ };
144
+ const hit = ([x, y]) => {
145
+ let best = null, bestD = Infinity;
146
+ for (const n of nodes) {
147
+ if (n.depth === 0) continue;
148
+ const d = Math.hypot(n.x - x, n.y - y);
149
+ if (d < n.r + 8 / view.k && d < bestD) { best = n; bestD = d; }
150
+ }
151
+ return best;
152
+ };
153
+ let dragged = null;
154
+ const onDown = (e) => {
155
+ const n = hit(toGraph(e));
156
+ if (!n) return;
157
+ dragged = n;
158
+ n.fx = n.x; n.fy = n.y;
159
+ cv.setPointerCapture(e.pointerId);
160
+ cv.style.cursor = "grabbing";
161
+ sim.alphaTarget(0.25).restart();
162
+ e.preventDefault();
163
+ };
164
+ const onMove = (e) => {
165
+ const p = toGraph(e);
166
+ if (!dragged) { cv.style.cursor = hit(p) ? "grab" : ""; return; }
167
+ const d = Math.hypot(p[0], p[1]), lim = bound - dragged.r;
168
+ const k = d > lim ? lim / d : 1;
169
+ dragged.fx = p[0] * k; dragged.fy = p[1] * k;
170
+ };
171
+ const onUp = (e) => {
172
+ if (!dragged) return;
173
+ dragged.fx = dragged.fy = null;
174
+ dragged = null;
175
+ cv.style.cursor = hit(toGraph(e)) ? "grab" : "";
176
+ sim.alphaTarget(0);
177
+ };
178
+ cv.addEventListener("pointerdown", onDown);
179
+ cv.addEventListener("pointermove", onMove);
180
+ cv.addEventListener("pointerup", onUp);
181
+ cv.addEventListener("pointercancel", onUp);
182
+
183
+ const hex = (x, y, R, draw) => { ctx.save(); ctx.translate(x, y); draw(new Path2D(hexPath(R))); ctx.restore(); };
184
+ const frame = (time) => {
185
+ const dpr = view.dpr = Math.min(2, devicePixelRatio || 1);
186
+ const cw = cv.clientWidth, ch = cv.clientHeight;
187
+ if (cv.width !== cw * dpr || cv.height !== ch * dpr) { cv.width = cw * dpr; cv.height = ch * dpr; }
188
+ view.cx = cw / 2; view.cy = ch * 0.42;
189
+ view.k = Math.min(1.15, Math.min(cw * 0.47, ch * 0.41) / bound);
190
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
191
+ ctx.clearRect(0, 0, cw, ch);
192
+ ctx.translate(view.cx, view.cy);
193
+ ctx.scale(view.k, view.k);
194
+ // a gentle drift, drawn only — the layout itself stays where it settled
195
+ const pos = (n) => (still || n === dragged || n.depth === 0) ? [n.x, n.y]
196
+ : [n.x + Math.sin(time / 2600 + n.ph) * 2.2, n.y + Math.cos(time / 3100 + n.ph) * 2.2];
197
+ for (const l of links) {
198
+ const [x1, y1] = pos(l.source), [x2, y2] = pos(l.target);
199
+ ctx.globalAlpha = l.cross ? 0.22 : 0.4;
200
+ ctx.strokeStyle = l.cross ? "#aeb6d6" : l.target.c;
201
+ ctx.lineWidth = l.cross ? 1 : 1.3;
202
+ ctx.setLineDash(l.cross ? [3, 6] : []);
203
+ ctx.beginPath(); ctx.moveTo(x1, y1);
204
+ ctx.quadraticCurveTo((x1 + x2) / 2 + (y2 - y1) * 0.12, (y1 + y2) / 2 - (x2 - x1) * 0.12, x2, y2);
205
+ ctx.stroke();
206
+ }
207
+ ctx.setLineDash([]);
208
+ for (const n of nodes) {
209
+ if (n.depth === 0) continue;
210
+ const [x, y] = pos(n);
211
+ hex(x, y, n.r, (path) => {
212
+ ctx.strokeStyle = n.c; ctx.fillStyle = n.c; ctx.lineWidth = n === dragged ? 2.6 : 1.8;
213
+ ctx.setLineDash(n.bare ? [3, 4] : []);
214
+ ctx.globalAlpha = n.bare ? 0.04 : n === dragged ? 0.3 : 0.15; ctx.fill(path);
215
+ ctx.globalAlpha = 1; ctx.stroke(path);
216
+ });
217
+ }
218
+ ctx.setLineDash([]);
219
+ ctx.globalAlpha = 1;
220
+ hex(0, 0, nodes[0].r, (p) => { ctx.fillStyle = "#121829"; ctx.strokeStyle = "#62e38d"; ctx.lineWidth = 2.4; ctx.fill(p); ctx.stroke(p); });
221
+ raf = requestAnimationFrame(frame);
222
+ };
223
+ raf = requestAnimationFrame(frame);
224
+ art = { sim, cv, off: () => {
225
+ cv.removeEventListener("pointerdown", onDown);
226
+ cv.removeEventListener("pointermove", onMove);
227
+ cv.removeEventListener("pointerup", onUp);
228
+ cv.removeEventListener("pointercancel", onUp);
229
+ cv.style.cursor = "";
230
+ } };
231
+ }
232
+
233
+ function stopArt() {
234
+ if (raf) cancelAnimationFrame(raf);
235
+ raf = null;
236
+ if (art) { art.sim.stop(); art.off(); art = null; }
237
+ }
@@ -0,0 +1,46 @@
1
+ // A record's body as Markdown: headings, lists, tables, code, links — and the
2
+ // memory's own [[path]] links, which open the record they name.
3
+ //
4
+ // Bodies are written by agents and people, so nothing in them runs: raw HTML is
5
+ // shown as text, only http(s)/mailto links are kept (and open in a new tab),
6
+ // images are not fetched, and the result goes through DOMPurify as well.
7
+
8
+ import { Marked } from "../vendor/marked.esm.js";
9
+ import DOMPurify from "../vendor/purify.es.js";
10
+ import { esc } from "./ui.js";
11
+
12
+ let wikilink = (target, label) => esc(label);
13
+
14
+ const md = new Marked({ gfm: true, breaks: true, async: false });
15
+ md.use({
16
+ extensions: [{
17
+ name: "wikilink",
18
+ level: "inline",
19
+ start(src) { const i = src.indexOf("[["); return i < 0 ? undefined : i; },
20
+ tokenizer(src) {
21
+ const m = /^\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|([^\]\n]*))?\]\]/.exec(src);
22
+ if (m) return { type: "wikilink", raw: m[0], target: m[1].trim(), label: (m[2] || m[1]).trim() };
23
+ },
24
+ renderer(token) { return wikilink(token.target, token.label); },
25
+ }],
26
+ renderer: {
27
+ html(token) { return esc(token.text); },
28
+ link({ href, title, tokens }) {
29
+ const text = this.parser.parseInline(tokens);
30
+ if (!/^(https?:|mailto:)/i.test(href || "")) return text;
31
+ return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer nofollow"${title ? ` title="${esc(title)}"` : ""}>${text}</a>`;
32
+ },
33
+ image({ text }) { return text ? `<span class="md-img">[${esc(text)}]</span>` : ""; },
34
+ },
35
+ });
36
+
37
+ // `link(target, label)` returns the HTML for one [[…]] link.
38
+ export function renderMarkdown(body, { link } = {}) {
39
+ wikilink = link || ((t, label) => esc(label));
40
+ try {
41
+ const html = md.parse(String(body ?? ""));
42
+ return DOMPurify.sanitize(html, { ADD_ATTR: ["target"], FORBID_TAGS: ["style", "form", "input", "textarea", "select"] });
43
+ } finally {
44
+ wikilink = (t, label) => esc(label);
45
+ }
46
+ }