memgres 0.11.0__tar.gz → 0.12.1__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 (114) hide show
  1. {memgres-0.11.0 → memgres-0.12.1}/PKG-INFO +2 -1
  2. {memgres-0.11.0 → memgres-0.12.1}/README.md +1 -0
  3. {memgres-0.11.0 → memgres-0.12.1}/memgres/__init__.py +2 -1
  4. {memgres-0.11.0 → memgres-0.12.1}/memgres/_version.py +1 -1
  5. {memgres-0.11.0 → memgres-0.12.1}/memgres/config.py +6 -0
  6. {memgres-0.11.0 → memgres-0.12.1}/memgres/info.py +8 -0
  7. {memgres-0.11.0 → memgres-0.12.1}/memgres/links.py +57 -6
  8. {memgres-0.11.0 → memgres-0.12.1}/memgres/mcp_server.py +86 -44
  9. memgres-0.12.1/memgres/migrations/0023_relink_after_parser_fix.sql +16 -0
  10. memgres-0.12.1/memgres/paths.py +49 -0
  11. {memgres-0.11.0 → memgres-0.12.1}/memgres/schema.py +5 -1
  12. {memgres-0.11.0 → memgres-0.12.1}/memgres/store.py +71 -2
  13. {memgres-0.11.0 → memgres-0.12.1}/memgres/vector/base.py +5 -1
  14. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/PKG-INFO +2 -1
  15. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/SOURCES.txt +4 -0
  16. {memgres-0.11.0 → memgres-0.12.1}/tests/test_links.py +43 -1
  17. {memgres-0.11.0 → memgres-0.12.1}/tests/test_list.py +1 -1
  18. memgres-0.12.1/tests/test_mcp_error_messages.py +207 -0
  19. memgres-0.12.1/tests/test_required_fields.py +195 -0
  20. {memgres-0.11.0 → memgres-0.12.1}/tests/test_server_info.py +20 -1
  21. {memgres-0.11.0 → memgres-0.12.1}/tests/test_server_integration.py +2 -1
  22. {memgres-0.11.0 → memgres-0.12.1}/LICENSE +0 -0
  23. {memgres-0.11.0 → memgres-0.12.1}/memgres/admin.py +0 -0
  24. {memgres-0.11.0 → memgres-0.12.1}/memgres/admin_cli.py +0 -0
  25. {memgres-0.11.0 → memgres-0.12.1}/memgres/blame.py +0 -0
  26. {memgres-0.11.0 → memgres-0.12.1}/memgres/bootstrap.py +0 -0
  27. {memgres-0.11.0 → memgres-0.12.1}/memgres/delimiters.py +0 -0
  28. {memgres-0.11.0 → memgres-0.12.1}/memgres/diffing.py +0 -0
  29. {memgres-0.11.0 → memgres-0.12.1}/memgres/embed_worker.py +0 -0
  30. {memgres-0.11.0 → memgres-0.12.1}/memgres/embeddings.py +0 -0
  31. {memgres-0.11.0 → memgres-0.12.1}/memgres/healthcheck.py +0 -0
  32. {memgres-0.11.0 → memgres-0.12.1}/memgres/identity.py +0 -0
  33. {memgres-0.11.0 → memgres-0.12.1}/memgres/indexing.py +0 -0
  34. {memgres-0.11.0 → memgres-0.12.1}/memgres/lines.py +0 -0
  35. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0001_core.sql +0 -0
  36. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0002_identity.sql +0 -0
  37. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0003_history_author.sql +0 -0
  38. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0004_title.sql +0 -0
  39. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0005_chunk_index.sql +0 -0
  40. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0006_reader_floor.sql +0 -0
  41. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0007_embed_retry.sql +0 -0
  42. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0008_service_roles.sql +0 -0
  43. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  44. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0010_namespace_alias.sql +0 -0
  45. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  46. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0012_user_profile.sql +0 -0
  47. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0013_hash_version.sql +0 -0
  48. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  49. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0015_normalize_tags.sql +0 -0
  50. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0016_valid_at.sql +0 -0
  51. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0017_memory_link.sql +0 -0
  52. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0018_links_built.sql +0 -0
  53. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0019_memory_usage.sql +0 -0
  54. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  55. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0021_enrollment_key.sql +0 -0
  56. {memgres-0.11.0 → memgres-0.12.1}/memgres/migrations/0022_user_disabled.sql +0 -0
  57. {memgres-0.11.0 → memgres-0.12.1}/memgres/periodic.py +0 -0
  58. {memgres-0.11.0 → memgres-0.12.1}/memgres/reembed.py +0 -0
  59. {memgres-0.11.0 → memgres-0.12.1}/memgres/relink.py +0 -0
  60. {memgres-0.11.0 → memgres-0.12.1}/memgres/search.py +0 -0
  61. {memgres-0.11.0 → memgres-0.12.1}/memgres/segments.py +0 -0
  62. {memgres-0.11.0 → memgres-0.12.1}/memgres/server.py +0 -0
  63. {memgres-0.11.0 → memgres-0.12.1}/memgres/tags.py +0 -0
  64. {memgres-0.11.0 → memgres-0.12.1}/memgres/token_cli.py +0 -0
  65. {memgres-0.11.0 → memgres-0.12.1}/memgres/vector/__init__.py +0 -0
  66. {memgres-0.11.0 → memgres-0.12.1}/memgres/vector/pgvector.py +0 -0
  67. {memgres-0.11.0 → memgres-0.12.1}/memgres/vector/qdrant.py +0 -0
  68. {memgres-0.11.0 → memgres-0.12.1}/memgres/worker.py +0 -0
  69. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/dependency_links.txt +0 -0
  70. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/entry_points.txt +0 -0
  71. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/requires.txt +0 -0
  72. {memgres-0.11.0 → memgres-0.12.1}/memgres.egg-info/top_level.txt +0 -0
  73. {memgres-0.11.0 → memgres-0.12.1}/pyproject.toml +0 -0
  74. {memgres-0.11.0 → memgres-0.12.1}/setup.cfg +0 -0
  75. {memgres-0.11.0 → memgres-0.12.1}/tests/test_admin_two_way.py +0 -0
  76. {memgres-0.11.0 → memgres-0.12.1}/tests/test_blame_integration.py +0 -0
  77. {memgres-0.11.0 → memgres-0.12.1}/tests/test_chunk_index.py +0 -0
  78. {memgres-0.11.0 → memgres-0.12.1}/tests/test_claim_and_reembed.py +0 -0
  79. {memgres-0.11.0 → memgres-0.12.1}/tests/test_config.py +0 -0
  80. {memgres-0.11.0 → memgres-0.12.1}/tests/test_diffing.py +0 -0
  81. {memgres-0.11.0 → memgres-0.12.1}/tests/test_embed_worker.py +0 -0
  82. {memgres-0.11.0 → memgres-0.12.1}/tests/test_embeddings.py +0 -0
  83. {memgres-0.11.0 → memgres-0.12.1}/tests/test_enrollment.py +0 -0
  84. {memgres-0.11.0 → memgres-0.12.1}/tests/test_healthcheck.py +0 -0
  85. {memgres-0.11.0 → memgres-0.12.1}/tests/test_identity_integration.py +0 -0
  86. {memgres-0.11.0 → memgres-0.12.1}/tests/test_lexical_match.py +0 -0
  87. {memgres-0.11.0 → memgres-0.12.1}/tests/test_limits.py +0 -0
  88. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_admin_tools.py +0 -0
  89. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_http_transport.py +0 -0
  90. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_instructions.py +0 -0
  91. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_recall_schema.py +0 -0
  92. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_tool_visibility.py +0 -0
  93. {memgres-0.11.0 → memgres-0.12.1}/tests/test_mcp_tool_visibility_http.py +0 -0
  94. {memgres-0.11.0 → memgres-0.12.1}/tests/test_migration_upgrade.py +0 -0
  95. {memgres-0.11.0 → memgres-0.12.1}/tests/test_multi_space_search.py +0 -0
  96. {memgres-0.11.0 → memgres-0.12.1}/tests/test_path_addressing.py +0 -0
  97. {memgres-0.11.0 → memgres-0.12.1}/tests/test_qdrant_ca.py +0 -0
  98. {memgres-0.11.0 → memgres-0.12.1}/tests/test_qdrant_integration.py +0 -0
  99. {memgres-0.11.0 → memgres-0.12.1}/tests/test_replace_build.py +0 -0
  100. {memgres-0.11.0 → memgres-0.12.1}/tests/test_require_title.py +0 -0
  101. {memgres-0.11.0 → memgres-0.12.1}/tests/test_retention.py +0 -0
  102. {memgres-0.11.0 → memgres-0.12.1}/tests/test_roles_bootstrap.py +0 -0
  103. {memgres-0.11.0 → memgres-0.12.1}/tests/test_search_integration.py +0 -0
  104. {memgres-0.11.0 → memgres-0.12.1}/tests/test_security_followups.py +0 -0
  105. {memgres-0.11.0 → memgres-0.12.1}/tests/test_security_integration.py +0 -0
  106. {memgres-0.11.0 → memgres-0.12.1}/tests/test_segments.py +0 -0
  107. {memgres-0.11.0 → memgres-0.12.1}/tests/test_segments_store.py +0 -0
  108. {memgres-0.11.0 → memgres-0.12.1}/tests/test_snippets.py +0 -0
  109. {memgres-0.11.0 → memgres-0.12.1}/tests/test_store_integration.py +0 -0
  110. {memgres-0.11.0 → memgres-0.12.1}/tests/test_tags.py +0 -0
  111. {memgres-0.11.0 → memgres-0.12.1}/tests/test_token_sink.py +0 -0
  112. {memgres-0.11.0 → memgres-0.12.1}/tests/test_usage.py +0 -0
  113. {memgres-0.11.0 → memgres-0.12.1}/tests/test_valid_at.py +0 -0
  114. {memgres-0.11.0 → memgres-0.12.1}/tests/test_write_ergonomics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.11.0
3
+ Version: 0.12.1
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
@@ -216,6 +216,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
216
216
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
217
217
  | `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
218
218
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
219
+ | `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
219
220
  | `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
220
221
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
221
222
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
@@ -180,6 +180,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
180
180
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
181
181
  | `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
182
182
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
183
+ | `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
183
184
  | `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
184
185
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
185
186
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
@@ -23,7 +23,7 @@ from .schema import migrate, SchemaMismatch, SCHEMA_VERSION
23
23
  from .search import Hit, recall
24
24
  from .blame import annotate, annotate_grouped, reconstruct, replay
25
25
  from .store import (Store, Memory, Conflict, NotFound, TooLarge, NoParent,
26
- MissingTitle)
26
+ MissingTitle, MissingField)
27
27
  from .identity import (
28
28
  Principal, AuthError, SpaceNotFound,
29
29
  resolve, resolve_space, new_token, valid_format,
@@ -38,6 +38,7 @@ __all__ = [
38
38
  "Config", "load_config",
39
39
  "Store", "Memory", "Conflict", "NotFound", "TooLarge", "NoParent",
40
40
  "MissingTitle",
41
+ "MissingField",
41
42
  "make_diff", "apply_diff", "content_hash", "DiffConflict",
42
43
  "Embedder", "get_embedder",
43
44
  "migrate", "SchemaMismatch", "SCHEMA_VERSION",
@@ -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.11.0"
11
+ __version__ = "0.12.1"
@@ -79,6 +79,8 @@ class Config:
79
79
  # organization
80
80
  tree_enabled: bool # ltree path column + GiST index for fast subtree selection
81
81
  require_title: bool # True = a write that stores CONTENT must caption it
82
+ required_fields: tuple # extra fields a content-storing write must carry
83
+ # (MEMGRES_REQUIRED_FIELDS=source), refused when absent
82
84
  require_parent: bool # False = sparse paths (create food.apple with no food row);
83
85
  # True = a node's parent path must already exist as a memory
84
86
  # history
@@ -222,6 +224,10 @@ def load() -> Config:
222
224
  token_sink=_str("MEMGRES_TOKEN_SINK", ""),
223
225
  tree_enabled=_bool("MEMGRES_TREE", True),
224
226
  require_title=_bool("MEMGRES_REQUIRE_TITLE", True),
227
+ required_fields=tuple(
228
+ f for f in (x.strip().lower()
229
+ for x in _str("MEMGRES_REQUIRED_FIELDS", "").split(","))
230
+ if f),
225
231
  require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
226
232
  history_enabled=_bool("MEMGRES_HISTORY", True),
227
233
  fts_language=_str("MEMGRES_FTS_LANGUAGE", "simple"),
@@ -65,6 +65,14 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
65
65
  "renew_on_read": bool(days > 0 and cfg.renew_on_read),
66
66
  "policy": _retention_policy(days, cfg.renew_on_read),
67
67
  },
68
+ # What a write MUST carry here. Announced rather than discovered from a
69
+ # refusal: a client that learns the rule by being rejected has already
70
+ # composed the memory, and a rule nobody can see before writing is a rule
71
+ # that gets satisfied with junk on the second attempt.
72
+ "write_requirements": {
73
+ "title": cfg.require_title,
74
+ "fields": list(cfg.required_fields),
75
+ },
68
76
  "recall_modes": ["lexical"] if lexical_only
69
77
  else ["lexical", "semantic", "hybrid", "auto"],
70
78
  "vector_backend": cfg.vector_backend,
@@ -52,10 +52,11 @@ from typing import Dict, List, Optional
52
52
  # edges so they are visible, never resolved — we do not own the address space.
53
53
  KNOWN_SCHEMES = ("idea", "file")
54
54
 
55
- # An ltree path: labels of [A-Za-z0-9_] joined by dots. Deliberately strict —
56
- # this is what tells a path apart from a slug belonging to another store, and
57
- # from prose that happens to sit in double brackets.
58
- _PATH = re.compile(r"^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$")
55
+ # What a path is lives in `paths.py`, so the parser and the store cannot drift
56
+ # apart again — that drift is exactly what dropped every hyphenated link before
57
+ # 0.12.0. Kept under the old name because the rest of this module reads better
58
+ # with it.
59
+ from .paths import PATH_RE as _PATH
59
60
 
60
61
  _LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]")
61
62
  _FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.S)
@@ -82,8 +83,58 @@ class Link:
82
83
  end: int = 0
83
84
 
84
85
 
86
+ def _blank_indented(original: str, text: str) -> str:
87
+ """Blank markdown's OTHER code block: a run of lines indented by four spaces
88
+ (or a tab), opened by a blank line.
89
+
90
+ Fences are not the only way to show code, and the indented form is what a
91
+ hand-written example tends to use. A frp config pasted that way put
92
+ ``[[proxies]]`` — a TOML array-of-tables header, and a perfectly well-formed
93
+ path — into the link graph as a dangling edge to a memory nobody will ever
94
+ write. The parser must ignore code wherever markdown says code is.
95
+
96
+ A blank line inside the block does not end it; the first non-blank line back
97
+ at the margin does. Opening on a blank line is what keeps an ordinary list
98
+ item or a wrapped line — indented, but not preceded by a blank — from being
99
+ read as code.
100
+
101
+ 🔴 Which lines are code is decided from `original`, and the blanking applied
102
+ to `text` — the copy where spans and fences are already spaces. Deciding from
103
+ the blanked copy instead made every line that STARTED with an inline code
104
+ span look indented: "`[[a]]` but [[b]] counts" became four-plus leading
105
+ spaces, the whole line was swallowed as a block, and a real link next to a
106
+ code span disappeared. Both strings are length-preserving, so their lines
107
+ correspond one to one.
108
+ """
109
+ def blank(line: str) -> str:
110
+ return "".join(" " if c != "\n" else "\n" for c in line)
111
+
112
+ src = original.splitlines(keepends=True)
113
+ dst = text.splitlines(keepends=True)
114
+ out, in_block, prev_blank = [], False, True
115
+ for i, line in enumerate(src):
116
+ stripped = line.strip("\n")
117
+ is_blank = not stripped.strip()
118
+ indented = stripped.startswith(" ") or stripped.startswith("\t")
119
+ current = dst[i] if i < len(dst) else line
120
+ if in_block:
121
+ if is_blank or indented:
122
+ out.append(current if is_blank else blank(current))
123
+ else:
124
+ in_block = False
125
+ out.append(current)
126
+ elif indented and prev_blank:
127
+ in_block = True
128
+ out.append(blank(current))
129
+ else:
130
+ out.append(current)
131
+ prev_blank = is_blank
132
+ return "".join(out)
133
+
134
+
85
135
  def _blank_code(body: str) -> str:
86
- """Replace code spans and fenced blocks with spaces of the same length.
136
+ """Replace code spans and code blocks — fenced AND indented — with spaces of
137
+ the same length.
87
138
 
88
139
  Same length, not removal, so every offset in the returned text still lines up
89
140
  with the original — the parser does not need that today, but an anchor
@@ -92,7 +143,7 @@ def _blank_code(body: str) -> str:
92
143
  """
93
144
  def blank(m: re.Match) -> str:
94
145
  return "".join(" " if c != "\n" else "\n" for c in m.group(0))
95
- return _CODE_SPAN.sub(blank, _FENCE.sub(blank, body))
146
+ return _blank_indented(body, _CODE_SPAN.sub(blank, _FENCE.sub(blank, body)))
96
147
 
97
148
 
98
149
  def _classify(target: str) -> Optional[str]:
@@ -32,8 +32,10 @@ from typing_extensions import Annotated
32
32
 
33
33
  try: # mcp SDK >= 2.0 renamed the module fastmcp -> mcpserver
34
34
  from mcp.server.mcpserver import Context
35
+ from mcp.server.mcpserver.exceptions import ToolError
35
36
  except ImportError: # mcp SDK 1.x
36
37
  from mcp.server.fastmcp import Context
38
+ from mcp.server.fastmcp.exceptions import ToolError
37
39
 
38
40
  from . import admin, identity
39
41
  from .config import Config, load
@@ -269,6 +271,40 @@ def _mcp(name: str, instructions: Optional[str] = None):
269
271
  return server
270
272
 
271
273
 
274
+ # Refusals whose text is written FOR the caller and safe to show them: what is
275
+ # missing, which namespace to name, why the diff did not apply, what they may
276
+ # not do. Everything else — a psycopg error, a bug — stays masked, because those
277
+ # messages carry schema and internals rather than instructions.
278
+ _SPEAKABLE = (ValueError, PermissionError, LookupError)
279
+
280
+
281
+ def _speaking(fn):
282
+ """Re-raise a domain refusal as the SDK's own error type, which is the only
283
+ kind whose message reaches the client.
284
+
285
+ Everything else the SDK catches becomes `Error executing tool <name>` with no
286
+ detail — and that is what the caller sees. The refusals here are written to
287
+ be read: "this deployment requires `source` … as an ADDRESS", "you can reach
288
+ 2 namespaces, name the one you mean". Masking them turns a fixable mistake
289
+ into a mystery, and an agent that cannot see the reason repeats the same call
290
+ — which is exactly what happened: five identical edits in a row, then the
291
+ same on reads, against a server that was explaining itself into the void.
292
+ """
293
+ import functools
294
+
295
+ @functools.wraps(fn)
296
+ def wrapper(*args, **kwargs):
297
+ try:
298
+ return fn(*args, **kwargs)
299
+ except ToolError:
300
+ raise
301
+ except _SPEAKABLE as e:
302
+ # `from e` keeps the original in the server log, where the traceback
303
+ # belongs; the client gets the sentence, not the stack.
304
+ raise ToolError(str(e)) from e
305
+ return wrapper
306
+
307
+
272
308
  def _mem(m) -> dict:
273
309
  return m.to_dict(stringify_dates=True) # MCP layer needs plain strings
274
310
 
@@ -292,6 +328,12 @@ def build_server(cfg: Optional[Config] = None):
292
328
  _worker, cfg, backend = wire_server(cfg, embedder)
293
329
  mcp = _mcp("memgres", instructions=_instruction_text())
294
330
 
331
+ def tool(*d_args, **d_kwargs):
332
+ """`mcp.tool()`, but the refusal text survives the trip to the client."""
333
+ def deco(fn):
334
+ return mcp.tool(*d_args, **d_kwargs)(_speaking(fn))
335
+ return deco
336
+
295
337
  import os as _os
296
338
 
297
339
  # The control-plane tools. `auto` registers them wherever there are
@@ -389,7 +431,7 @@ def build_server(cfg: Optional[Config] = None):
389
431
  """
390
432
  return identity.resolve(conn, cfg, token)
391
433
 
392
- @mcp.tool()
434
+ @tool()
393
435
  def memory_write(body: Optional[str] = None, id: Optional[str] = None,
394
436
  at: Optional[str] = None,
395
437
  if_moved: IfMovedArg = "error",
@@ -445,7 +487,7 @@ def build_server(cfg: Optional[Config] = None):
445
487
  reason=reason, valid_at=valid_at, space=space,
446
488
  space_id=space_id))
447
489
 
448
- @mcp.tool()
490
+ @tool()
449
491
  def memory_get(id: Optional[str] = None, at: Optional[str] = None,
450
492
  if_moved: Literal["follow", "error"] = "follow",
451
493
  lines: Optional[str] = None,
@@ -471,7 +513,7 @@ def build_server(cfg: Optional[Config] = None):
471
513
  if_moved=if_moved, lines=lines,
472
514
  space=space, space_id=space_id))
473
515
 
474
- @mcp.tool()
516
+ @tool()
475
517
  def memory_recall(query: str, k: int = 10,
476
518
  mode: Literal["lexical", "semantic", "hybrid", "auto"] = "auto",
477
519
  match: Optional[Literal["any", "all"]] = None,
@@ -515,7 +557,7 @@ def build_server(cfg: Optional[Config] = None):
515
557
  snippet=snippet, full_body=full_body, bodies=bodies,
516
558
  match_tags=match_tags, space=space, space_id=space_id)]
517
559
 
518
- @mcp.tool()
560
+ @tool()
519
561
  def memory_list(path_prefix: Optional[str] = None,
520
562
  tags: Optional[List[str]] = None, limit: int = 50,
521
563
  offset: int = 0, bodies: bool = False,
@@ -543,7 +585,7 @@ def build_server(cfg: Optional[Config] = None):
543
585
  limit=limit, offset=offset, bodies=bodies,
544
586
  match_tags=match_tags, space=space, space_id=space_id)
545
587
 
546
- @mcp.tool()
588
+ @tool()
547
589
  def memory_tags(prefix: Optional[str] = None, k: int = 50,
548
590
  space: Spaces = None, space_id: Spaces = None,
549
591
  ctx: Context = None) -> List[dict]:
@@ -558,7 +600,7 @@ def build_server(cfg: Optional[Config] = None):
558
600
  return _store(conn).tags(_token(ctx), prefix=prefix, k=k,
559
601
  space=space, space_id=space_id)
560
602
 
561
- @mcp.tool()
603
+ @tool()
562
604
  def memory_links(id: Optional[str] = None, at: Optional[str] = None,
563
605
  direction: Literal["in", "out", "both"] = "both",
564
606
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -579,7 +621,7 @@ def build_server(cfg: Optional[Config] = None):
579
621
  at=at or None, direction=direction,
580
622
  space=space, space_id=space_id)
581
623
 
582
- @mcp.tool()
624
+ @tool()
583
625
  def memory_server_info(ctx: Context = None) -> dict:
584
626
  """The server's version + schema_version and its effective limits and
585
627
  capabilities (write ceilings, embed provider/model/dim, available recall
@@ -589,7 +631,7 @@ def build_server(cfg: Optional[Config] = None):
589
631
  dim = embedder.dim if embedder is not None else None
590
632
  return server_info(cfg, embed_dim=dim)
591
633
 
592
- @mcp.tool()
634
+ @tool()
593
635
  def memory_blame(id: Optional[str] = None, at: Optional[str] = None,
594
636
  grouped: bool = True, lines: Optional[str] = None,
595
637
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -609,7 +651,7 @@ def build_server(cfg: Optional[Config] = None):
609
651
  return s.annotate(tok, id, lines=want, at=at, space=space,
610
652
  space_id=space_id)
611
653
 
612
- @mcp.tool()
654
+ @tool()
613
655
  def memory_history(id: Optional[str] = None, at: Optional[str] = None,
614
656
  space: Optional[str] = None,
615
657
  space_id: Optional[str] = None,
@@ -622,7 +664,7 @@ def build_server(cfg: Optional[Config] = None):
622
664
  return _store(conn).history(_token(ctx), id, at=at,
623
665
  space=space, space_id=space_id)
624
666
 
625
- @mcp.tool()
667
+ @tool()
626
668
  def memory_move(new_path: str, id: Optional[str] = None,
627
669
  at: Optional[str] = None,
628
670
  if_moved: Literal["error", "follow"] = "error",
@@ -638,7 +680,7 @@ def build_server(cfg: Optional[Config] = None):
638
680
  reason=reason, space=space,
639
681
  space_id=space_id))
640
682
 
641
- @mcp.tool()
683
+ @tool()
642
684
  def memory_forget(id: Optional[str] = None, at: Optional[str] = None,
643
685
  space: Optional[str] = None,
644
686
  space_id: Optional[str] = None,
@@ -652,7 +694,7 @@ def build_server(cfg: Optional[Config] = None):
652
694
  _token(ctx), id, at=at, space=space, space_id=space_id)}
653
695
 
654
696
  # ─── identity: spaces & tokens (open/managed modes) ─────────────────────
655
- @mcp.tool()
697
+ @tool()
656
698
  def memory_list_spaces(ctx: Context = None) -> List[dict]:
657
699
  """List the namespaces you can reach — your own plus any shared with you —
658
700
  with each one's id, name, description, permission, and your `alias` for it
@@ -661,7 +703,7 @@ def build_server(cfg: Optional[Config] = None):
661
703
  with pool.connection() as conn, conn.transaction():
662
704
  return identity.list_spaces(conn, _uid(conn, _token(ctx)))
663
705
 
664
- @mcp.tool()
706
+ @tool()
665
707
  def memory_create_space(name: str, description: str = "",
666
708
  instruction: str = "",
667
709
  ctx: Context = None) -> dict:
@@ -675,7 +717,7 @@ def build_server(cfg: Optional[Config] = None):
675
717
  description=description, instruction=instruction)
676
718
  return {"id": nsid, "name": name}
677
719
 
678
- @mcp.tool()
720
+ @tool()
679
721
  def memory_set_alias(alias: str, space_id: str,
680
722
  ctx: Context = None) -> dict:
681
723
  """Give a namespace a name of your own, for when a bare name is ambiguous
@@ -688,7 +730,7 @@ def build_server(cfg: Optional[Config] = None):
688
730
  identity.create_alias(conn, uid, alias, space_id)
689
731
  return {"alias": alias, "space_id": space_id}
690
732
 
691
- @mcp.tool()
733
+ @tool()
692
734
  def memory_drop_alias(alias: str,
693
735
  ctx: Context = None) -> dict:
694
736
  """Remove one of your namespace aliases. The namespace itself is
@@ -698,7 +740,7 @@ def build_server(cfg: Optional[Config] = None):
698
740
  return {"dropped": identity.drop_alias(conn, uid, alias)}
699
741
 
700
742
  if cfg.key_mode == "managed":
701
- @mcp.tool()
743
+ @tool()
702
744
  def memory_enroll(key: EnrollKeyArg, ctx: Context = None) -> dict:
703
745
  """Claim an account with a one-time enrollment key.
704
746
 
@@ -734,7 +776,7 @@ def build_server(cfg: Optional[Config] = None):
734
776
  "tools you can now use are not showing yet.")
735
777
  return out
736
778
 
737
- @mcp.tool()
779
+ @tool()
738
780
  def memory_issue_token(permission: str = "write", space: Optional[str] = None,
739
781
  space_id: Optional[str] = None, label: str = "",
740
782
  expires_days: Optional[int] = None,
@@ -789,7 +831,7 @@ def build_server(cfg: Optional[Config] = None):
789
831
  out.update({"permission": permission, "namespace_id": nsid})
790
832
  return out
791
833
 
792
- @mcp.tool()
834
+ @tool()
793
835
  def memory_request_access(space_id: str, permission: str = "read",
794
836
  ctx: Context = None) -> dict:
795
837
  """Ask the owner of a namespace to let you in.
@@ -804,14 +846,14 @@ def build_server(cfg: Optional[Config] = None):
804
846
  namespace_id=space_id,
805
847
  permission=permission)
806
848
 
807
- @mcp.tool()
849
+ @tool()
808
850
  def memory_list_tokens(ctx: Context = None) -> List[dict]:
809
851
  """List your tokens (metadata only — never the secret)."""
810
852
  with pool.connection() as conn, conn.transaction():
811
853
  out = identity.list_tokens(conn, _admin_uid(conn, _token(ctx)))
812
854
  return _iso(out, *_TOKEN_TIMES)
813
855
 
814
- @mcp.tool()
856
+ @tool()
815
857
  def memory_revoke_token(token_id: str,
816
858
  ctx: Context = None) -> dict:
817
859
  """Revoke one of your tokens by id (kills it immediately)."""
@@ -822,7 +864,7 @@ def build_server(cfg: Optional[Config] = None):
822
864
  raise identity.AuthError("not your token")
823
865
  return {"revoked": identity.revoke_token(conn, token_id)}
824
866
 
825
- @mcp.tool()
867
+ @tool()
826
868
  def memory_whoami(ctx: Context = None) -> dict:
827
869
  """Who you are and what you may do: user id, service role, this
828
870
  credential's permission ceiling and namespace scope, plus the
@@ -834,7 +876,7 @@ def build_server(cfg: Optional[Config] = None):
834
876
  # ─── control plane: provisioning (authorized in `admin`, not here) ───────
835
877
  if admin_surface:
836
878
 
837
- @mcp.tool()
879
+ @tool()
838
880
  def memory_admin_list_users(role: Optional[str] = None, limit: int = 50,
839
881
  offset: int = 0,
840
882
  ctx: Context = None) -> List[dict]:
@@ -846,7 +888,7 @@ def build_server(cfg: Optional[Config] = None):
846
888
  role=role, limit=limit, offset=offset)
847
889
  return _iso(out, "created_at")
848
890
 
849
- @mcp.tool()
891
+ @tool()
850
892
  def memory_admin_create_user(name: str = "", description: str = "",
851
893
  role: str = "user",
852
894
  can_create_namespace: bool = False,
@@ -872,7 +914,7 @@ def build_server(cfg: Optional[Config] = None):
872
914
  department=department, position=position)
873
915
  return {"id": uid}
874
916
 
875
- @mcp.tool()
917
+ @tool()
876
918
  def memory_admin_edit_user(user_id: str,
877
919
  name: Optional[str] = None,
878
920
  description: Optional[str] = None,
@@ -891,7 +933,7 @@ def build_server(cfg: Optional[Config] = None):
891
933
  conn, _principal(conn, _token(ctx)), user_id=user_id,
892
934
  **{k: v for k, v in fields.items() if v is not None})
893
935
 
894
- @mcp.tool()
936
+ @tool()
895
937
  def memory_admin_set_can_create_namespace(user_id: str, allowed: bool,
896
938
  ctx: Context = None) -> dict:
897
939
  """Grant or withdraw a user's right to create namespaces. Without it
@@ -902,7 +944,7 @@ def build_server(cfg: Optional[Config] = None):
902
944
  conn, _principal(conn, _token(ctx)),
903
945
  user_id=user_id, allowed=allowed)
904
946
 
905
- @mcp.tool()
947
+ @tool()
906
948
  def memory_admin_set_role(user_id: str, role: str,
907
949
  ctx: Context = None) -> dict:
908
950
  """Set a user's service role (user | user_manager | superadmin).
@@ -912,7 +954,7 @@ def build_server(cfg: Optional[Config] = None):
912
954
  return admin.set_role(conn, _principal(conn, _token(ctx)),
913
955
  user_id=user_id, role=role)
914
956
 
915
- @mcp.tool()
957
+ @tool()
916
958
  def memory_admin_list_namespaces(owner_user_id: Optional[str] = None,
917
959
  limit: int = 50, offset: int = 0,
918
960
  ctx: Context = None) -> List[dict]:
@@ -925,7 +967,7 @@ def build_server(cfg: Optional[Config] = None):
925
967
  owner_user_id=owner_user_id, limit=limit, offset=offset)
926
968
  return _iso(out, "created_at")
927
969
 
928
- @mcp.tool()
970
+ @tool()
929
971
  def memory_admin_create_namespace(name: str, owner_user_id: str,
930
972
  description: str = "",
931
973
  instruction: str = "",
@@ -940,7 +982,7 @@ def build_server(cfg: Optional[Config] = None):
940
982
  description=description, instruction=instruction)
941
983
  return {"id": nsid}
942
984
 
943
- @mcp.tool()
985
+ @tool()
944
986
  def memory_admin_edit_namespace(space_id: str,
945
987
  description: Optional[str] = None,
946
988
  instruction: Optional[str] = None,
@@ -959,7 +1001,7 @@ def build_server(cfg: Optional[Config] = None):
959
1001
  namespace_id=space_id, description=description,
960
1002
  instruction=instruction, name=name)
961
1003
 
962
- @mcp.tool()
1004
+ @tool()
963
1005
  def memory_admin_count_orphans(ctx: Context = None) -> dict:
964
1006
  """How many memories are stranded in the pre-identity namespace.
965
1007
  `single` mode stores everything under one nameless namespace; after a
@@ -969,7 +1011,7 @@ def build_server(cfg: Optional[Config] = None):
969
1011
  with pool.connection() as conn:
970
1012
  return admin.count_orphans(conn, _principal(conn, _token(ctx)))
971
1013
 
972
- @mcp.tool()
1014
+ @tool()
973
1015
  def memory_admin_adopt_orphans(space_id: str,
974
1016
  ctx: Context = None) -> dict:
975
1017
  """Move every stranded `single`-mode memory into a real namespace.
@@ -981,7 +1023,7 @@ def build_server(cfg: Optional[Config] = None):
981
1023
  conn, _principal(conn, _token(ctx)),
982
1024
  namespace_id=space_id, vectors=backend)
983
1025
 
984
- @mcp.tool()
1026
+ @tool()
985
1027
  def memory_admin_add_member(space_id: str, user_id: str,
986
1028
  permission: str = "read",
987
1029
  ctx: Context = None) -> dict:
@@ -994,7 +1036,7 @@ def build_server(cfg: Optional[Config] = None):
994
1036
  namespace_id=space_id, user_id=user_id,
995
1037
  permission=permission)
996
1038
 
997
- @mcp.tool()
1039
+ @tool()
998
1040
  def memory_admin_remove_member(space_id: str, user_id: str,
999
1041
  ctx: Context = None) -> dict:
1000
1042
  """Take a shared namespace away again. `removed: false` means they
@@ -1009,7 +1051,7 @@ def build_server(cfg: Optional[Config] = None):
1009
1051
  return admin.remove_member(conn, _principal(conn, _token(ctx)),
1010
1052
  namespace_id=space_id, user_id=user_id)
1011
1053
 
1012
- @mcp.tool()
1054
+ @tool()
1013
1055
  def memory_admin_transfer_namespace(
1014
1056
  space_id: str, new_owner_user_id: str,
1015
1057
  keep_previous_owner: Optional[str] = "admin",
@@ -1028,7 +1070,7 @@ def build_server(cfg: Optional[Config] = None):
1028
1070
  new_owner_user_id=new_owner_user_id,
1029
1071
  keep_previous_owner=keep_previous_owner)
1030
1072
 
1031
- @mcp.tool()
1073
+ @tool()
1032
1074
  def memory_admin_list_requests(space_id: str,
1033
1075
  ctx: Context = None) -> List[dict]:
1034
1076
  """Pending requests to join a namespace you administer — who asked,
@@ -1038,7 +1080,7 @@ def build_server(cfg: Optional[Config] = None):
1038
1080
  namespace_id=space_id)
1039
1081
  return _iso(out, "created_at")
1040
1082
 
1041
- @mcp.tool()
1083
+ @tool()
1042
1084
  def memory_admin_decide_access(request_id: str, approve: bool = True,
1043
1085
  expect_permission: Optional[str] = None,
1044
1086
  ctx: Context = None) -> dict:
@@ -1056,7 +1098,7 @@ def build_server(cfg: Optional[Config] = None):
1056
1098
  expect_permission=expect_permission)
1057
1099
  return {"request_id": request_id, "approved": approve}
1058
1100
 
1059
- @mcp.tool()
1101
+ @tool()
1060
1102
  def memory_admin_set_disabled(user_id: str, disabled: bool = True,
1061
1103
  ctx: Context = None) -> dict:
1062
1104
  """Switch an account off, or back on.
@@ -1070,7 +1112,7 @@ def build_server(cfg: Optional[Config] = None):
1070
1112
  return admin.set_disabled(conn, _principal(conn, _token(ctx)),
1071
1113
  user_id=user_id, disabled=disabled)
1072
1114
 
1073
- @mcp.tool()
1115
+ @tool()
1074
1116
  def memory_admin_list_members(space_id: str,
1075
1117
  ctx: Context = None) -> List[dict]:
1076
1118
  """Who can reach a namespace — the owner first, then everyone shared
@@ -1080,7 +1122,7 @@ def build_server(cfg: Optional[Config] = None):
1080
1122
  namespace_id=space_id)
1081
1123
  return _iso(out, "created_at")
1082
1124
 
1083
- @mcp.tool()
1125
+ @tool()
1084
1126
  def memory_admin_issue_token(user_id: str, permission: str = "write",
1085
1127
  space_id: Optional[str] = None,
1086
1128
  label: str = "",
@@ -1112,7 +1154,7 @@ def build_server(cfg: Optional[Config] = None):
1112
1154
  out["warning"] = minted["warning"]
1113
1155
  return out
1114
1156
 
1115
- @mcp.tool()
1157
+ @tool()
1116
1158
  def memory_admin_create_enrollment(
1117
1159
  user_id: str, permission: str = "write",
1118
1160
  space_id: Optional[str] = None, label: str = "",
@@ -1146,7 +1188,7 @@ def build_server(cfg: Optional[Config] = None):
1146
1188
  out["expires_at"] = out["expires_at"].isoformat()
1147
1189
  return admin.deliver_key(out, cfg.token_sink)
1148
1190
 
1149
- @mcp.tool()
1191
+ @tool()
1150
1192
  def memory_admin_list_enrollments(user_id: Optional[str] = None,
1151
1193
  ctx: Context = None) -> List[dict]:
1152
1194
  """Enrollment keys and what became of them — `state` is pending,
@@ -1158,7 +1200,7 @@ def build_server(cfg: Optional[Config] = None):
1158
1200
  user_id=user_id)
1159
1201
  return _iso(out, "created_at", "expires_at", "used_at", "revoked_at")
1160
1202
 
1161
- @mcp.tool()
1203
+ @tool()
1162
1204
  def memory_admin_revoke_enrollment(enrollment_id: str,
1163
1205
  ctx: Context = None) -> dict:
1164
1206
  """Kill an unredeemed enrollment key. False means it was already
@@ -1169,7 +1211,7 @@ def build_server(cfg: Optional[Config] = None):
1169
1211
  conn, _principal(conn, _token(ctx)),
1170
1212
  enrollment_id=enrollment_id)}
1171
1213
 
1172
- @mcp.tool()
1214
+ @tool()
1173
1215
  def memory_admin_list_tokens(user_id: str,
1174
1216
  ctx: Context = None) -> List[dict]:
1175
1217
  """A user's tokens — metadata only, never the secret."""
@@ -1178,7 +1220,7 @@ def build_server(cfg: Optional[Config] = None):
1178
1220
  user_id=user_id)
1179
1221
  return _iso(out, *_TOKEN_TIMES)
1180
1222
 
1181
- @mcp.tool()
1223
+ @tool()
1182
1224
  def memory_admin_revoke_token(token_id: str,
1183
1225
  ctx: Context = None) -> dict:
1184
1226
  """Kill any user's token immediately. False means it was already
@@ -0,0 +1,16 @@
1
+ -- Re-derive the link graph, because the PARSER changed, not the schema.
2
+ --
3
+ -- Until now `_PATH` rejected the hyphen, so every `[[infra.servers.video-production]]`
4
+ -- was classified as prose and dropped — not stored as a dangling edge, dropped —
5
+ -- while `[[proxies]]` from a TOML example indented as code was stored as a real
6
+ -- one. Both halves are fixed in `links.py`; neither fix reaches a body that is
7
+ -- already written, since edges are derived on write.
8
+ --
9
+ -- Clearing the flag makes the existing one-time backfill (`relink.rebuild`, run
10
+ -- at startup by `maybe_backfill`) do the pass again over every body. It rewrites
11
+ -- only the edge table: bodies, history and the hash chain are untouched, so this
12
+ -- is repeatable and costs nothing but a scan.
13
+ --
14
+ -- Additive by nature — an older client reading this database is no worse off
15
+ -- than it was, so the compatibility floor does not move.
16
+ UPDATE memgres_meta SET links_built = false;