memgres 0.12.0__tar.gz → 0.12.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 (114) hide show
  1. {memgres-0.12.0 → memgres-0.12.2}/PKG-INFO +1 -1
  2. {memgres-0.12.0 → memgres-0.12.2}/memgres/_version.py +1 -1
  3. {memgres-0.12.0 → memgres-0.12.2}/memgres/links.py +5 -13
  4. {memgres-0.12.0 → memgres-0.12.2}/memgres/mcp_server.py +95 -45
  5. memgres-0.12.2/memgres/paths.py +49 -0
  6. {memgres-0.12.0 → memgres-0.12.2}/memgres/store.py +24 -7
  7. {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/base.py +17 -3
  8. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/PKG-INFO +1 -1
  9. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/SOURCES.txt +2 -0
  10. memgres-0.12.2/tests/test_mcp_error_messages.py +207 -0
  11. {memgres-0.12.0 → memgres-0.12.2}/tests/test_required_fields.py +20 -0
  12. {memgres-0.12.0 → memgres-0.12.2}/tests/test_store_integration.py +11 -0
  13. {memgres-0.12.0 → memgres-0.12.2}/LICENSE +0 -0
  14. {memgres-0.12.0 → memgres-0.12.2}/README.md +0 -0
  15. {memgres-0.12.0 → memgres-0.12.2}/memgres/__init__.py +0 -0
  16. {memgres-0.12.0 → memgres-0.12.2}/memgres/admin.py +0 -0
  17. {memgres-0.12.0 → memgres-0.12.2}/memgres/admin_cli.py +0 -0
  18. {memgres-0.12.0 → memgres-0.12.2}/memgres/blame.py +0 -0
  19. {memgres-0.12.0 → memgres-0.12.2}/memgres/bootstrap.py +0 -0
  20. {memgres-0.12.0 → memgres-0.12.2}/memgres/config.py +0 -0
  21. {memgres-0.12.0 → memgres-0.12.2}/memgres/delimiters.py +0 -0
  22. {memgres-0.12.0 → memgres-0.12.2}/memgres/diffing.py +0 -0
  23. {memgres-0.12.0 → memgres-0.12.2}/memgres/embed_worker.py +0 -0
  24. {memgres-0.12.0 → memgres-0.12.2}/memgres/embeddings.py +0 -0
  25. {memgres-0.12.0 → memgres-0.12.2}/memgres/healthcheck.py +0 -0
  26. {memgres-0.12.0 → memgres-0.12.2}/memgres/identity.py +0 -0
  27. {memgres-0.12.0 → memgres-0.12.2}/memgres/indexing.py +0 -0
  28. {memgres-0.12.0 → memgres-0.12.2}/memgres/info.py +0 -0
  29. {memgres-0.12.0 → memgres-0.12.2}/memgres/lines.py +0 -0
  30. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0001_core.sql +0 -0
  31. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0002_identity.sql +0 -0
  32. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0003_history_author.sql +0 -0
  33. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0004_title.sql +0 -0
  34. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0005_chunk_index.sql +0 -0
  35. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0006_reader_floor.sql +0 -0
  36. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0007_embed_retry.sql +0 -0
  37. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0008_service_roles.sql +0 -0
  38. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  39. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
  40. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  41. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0012_user_profile.sql +0 -0
  42. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0013_hash_version.sql +0 -0
  43. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  44. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
  45. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0016_valid_at.sql +0 -0
  46. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0017_memory_link.sql +0 -0
  47. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0018_links_built.sql +0 -0
  48. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0019_memory_usage.sql +0 -0
  49. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  50. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0021_enrollment_key.sql +0 -0
  51. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0022_user_disabled.sql +0 -0
  52. {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
  53. {memgres-0.12.0 → memgres-0.12.2}/memgres/periodic.py +0 -0
  54. {memgres-0.12.0 → memgres-0.12.2}/memgres/reembed.py +0 -0
  55. {memgres-0.12.0 → memgres-0.12.2}/memgres/relink.py +0 -0
  56. {memgres-0.12.0 → memgres-0.12.2}/memgres/schema.py +0 -0
  57. {memgres-0.12.0 → memgres-0.12.2}/memgres/search.py +0 -0
  58. {memgres-0.12.0 → memgres-0.12.2}/memgres/segments.py +0 -0
  59. {memgres-0.12.0 → memgres-0.12.2}/memgres/server.py +0 -0
  60. {memgres-0.12.0 → memgres-0.12.2}/memgres/tags.py +0 -0
  61. {memgres-0.12.0 → memgres-0.12.2}/memgres/token_cli.py +0 -0
  62. {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/__init__.py +0 -0
  63. {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/pgvector.py +0 -0
  64. {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/qdrant.py +0 -0
  65. {memgres-0.12.0 → memgres-0.12.2}/memgres/worker.py +0 -0
  66. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/dependency_links.txt +0 -0
  67. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/entry_points.txt +0 -0
  68. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/requires.txt +0 -0
  69. {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/top_level.txt +0 -0
  70. {memgres-0.12.0 → memgres-0.12.2}/pyproject.toml +0 -0
  71. {memgres-0.12.0 → memgres-0.12.2}/setup.cfg +0 -0
  72. {memgres-0.12.0 → memgres-0.12.2}/tests/test_admin_two_way.py +0 -0
  73. {memgres-0.12.0 → memgres-0.12.2}/tests/test_blame_integration.py +0 -0
  74. {memgres-0.12.0 → memgres-0.12.2}/tests/test_chunk_index.py +0 -0
  75. {memgres-0.12.0 → memgres-0.12.2}/tests/test_claim_and_reembed.py +0 -0
  76. {memgres-0.12.0 → memgres-0.12.2}/tests/test_config.py +0 -0
  77. {memgres-0.12.0 → memgres-0.12.2}/tests/test_diffing.py +0 -0
  78. {memgres-0.12.0 → memgres-0.12.2}/tests/test_embed_worker.py +0 -0
  79. {memgres-0.12.0 → memgres-0.12.2}/tests/test_embeddings.py +0 -0
  80. {memgres-0.12.0 → memgres-0.12.2}/tests/test_enrollment.py +0 -0
  81. {memgres-0.12.0 → memgres-0.12.2}/tests/test_healthcheck.py +0 -0
  82. {memgres-0.12.0 → memgres-0.12.2}/tests/test_identity_integration.py +0 -0
  83. {memgres-0.12.0 → memgres-0.12.2}/tests/test_lexical_match.py +0 -0
  84. {memgres-0.12.0 → memgres-0.12.2}/tests/test_limits.py +0 -0
  85. {memgres-0.12.0 → memgres-0.12.2}/tests/test_links.py +0 -0
  86. {memgres-0.12.0 → memgres-0.12.2}/tests/test_list.py +0 -0
  87. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_admin_tools.py +0 -0
  88. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_http_transport.py +0 -0
  89. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_instructions.py +0 -0
  90. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_recall_schema.py +0 -0
  91. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_tool_visibility.py +0 -0
  92. {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_tool_visibility_http.py +0 -0
  93. {memgres-0.12.0 → memgres-0.12.2}/tests/test_migration_upgrade.py +0 -0
  94. {memgres-0.12.0 → memgres-0.12.2}/tests/test_multi_space_search.py +0 -0
  95. {memgres-0.12.0 → memgres-0.12.2}/tests/test_path_addressing.py +0 -0
  96. {memgres-0.12.0 → memgres-0.12.2}/tests/test_qdrant_ca.py +0 -0
  97. {memgres-0.12.0 → memgres-0.12.2}/tests/test_qdrant_integration.py +0 -0
  98. {memgres-0.12.0 → memgres-0.12.2}/tests/test_replace_build.py +0 -0
  99. {memgres-0.12.0 → memgres-0.12.2}/tests/test_require_title.py +0 -0
  100. {memgres-0.12.0 → memgres-0.12.2}/tests/test_retention.py +0 -0
  101. {memgres-0.12.0 → memgres-0.12.2}/tests/test_roles_bootstrap.py +0 -0
  102. {memgres-0.12.0 → memgres-0.12.2}/tests/test_search_integration.py +0 -0
  103. {memgres-0.12.0 → memgres-0.12.2}/tests/test_security_followups.py +0 -0
  104. {memgres-0.12.0 → memgres-0.12.2}/tests/test_security_integration.py +0 -0
  105. {memgres-0.12.0 → memgres-0.12.2}/tests/test_segments.py +0 -0
  106. {memgres-0.12.0 → memgres-0.12.2}/tests/test_segments_store.py +0 -0
  107. {memgres-0.12.0 → memgres-0.12.2}/tests/test_server_info.py +0 -0
  108. {memgres-0.12.0 → memgres-0.12.2}/tests/test_server_integration.py +0 -0
  109. {memgres-0.12.0 → memgres-0.12.2}/tests/test_snippets.py +0 -0
  110. {memgres-0.12.0 → memgres-0.12.2}/tests/test_tags.py +0 -0
  111. {memgres-0.12.0 → memgres-0.12.2}/tests/test_token_sink.py +0 -0
  112. {memgres-0.12.0 → memgres-0.12.2}/tests/test_usage.py +0 -0
  113. {memgres-0.12.0 → memgres-0.12.2}/tests/test_valid_at.py +0 -0
  114. {memgres-0.12.0 → memgres-0.12.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.12.0
3
+ Version: 0.12.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.12.0"
11
+ __version__ = "0.12.2"
@@ -52,19 +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
- #
59
- # The hyphen is there because PATHS MAY CONTAIN ONE. Postgres has allowed `-` in
60
- # ltree labels since 13 (the minimum this project supports), `write` accepts such
61
- # a path without complaint, and real corpora are full of them —
62
- # `infra.servers.video-production`. While this pattern rejected the hyphen the
63
- # two halves of the product disagreed about what a path is: the link was stored
64
- # as prose, `_classify` returned "ignore", and the edge did not even become a
65
- # DANGLING one. It vanished, and `memory_links` answered "nothing points here",
66
- # which reads as a fact about the corpus rather than as a parser that quit.
67
- _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
68
60
 
69
61
  _LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]")
70
62
  _FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.S)
@@ -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
@@ -59,7 +61,11 @@ SourceArg = Annotated[Optional[str], Field(
59
61
  "sender -> recipient, date, subject; messenger, who with whom, date; "
60
62
  "machine + project + session/transcript for an agent run; full URL + "
61
63
  "date read. 'email', 'the meeting', 'the user said' is not one — "
62
- "nothing can be reached through it, so the fact can only be believed.")]
64
+ "nothing can be reached through it, so the fact can only be believed. "
65
+ "It is recorded on THIS revision, not on the memory: a memory has no "
66
+ "single origin, its edits do. So a later read does not carry it — "
67
+ "`memory_blame` says where a given line came from, `memory_history` "
68
+ "where each revision did.")]
63
69
  ReasonArg = Annotated[Optional[str], Field(
64
70
  default=None,
65
71
  description="Why this write happened — what changed and why, in one line. Kept "
@@ -269,6 +275,40 @@ def _mcp(name: str, instructions: Optional[str] = None):
269
275
  return server
270
276
 
271
277
 
278
+ # Refusals whose text is written FOR the caller and safe to show them: what is
279
+ # missing, which namespace to name, why the diff did not apply, what they may
280
+ # not do. Everything else — a psycopg error, a bug — stays masked, because those
281
+ # messages carry schema and internals rather than instructions.
282
+ _SPEAKABLE = (ValueError, PermissionError, LookupError)
283
+
284
+
285
+ def _speaking(fn):
286
+ """Re-raise a domain refusal as the SDK's own error type, which is the only
287
+ kind whose message reaches the client.
288
+
289
+ Everything else the SDK catches becomes `Error executing tool <name>` with no
290
+ detail — and that is what the caller sees. The refusals here are written to
291
+ be read: "this deployment requires `source` … as an ADDRESS", "you can reach
292
+ 2 namespaces, name the one you mean". Masking them turns a fixable mistake
293
+ into a mystery, and an agent that cannot see the reason repeats the same call
294
+ — which is exactly what happened: five identical edits in a row, then the
295
+ same on reads, against a server that was explaining itself into the void.
296
+ """
297
+ import functools
298
+
299
+ @functools.wraps(fn)
300
+ def wrapper(*args, **kwargs):
301
+ try:
302
+ return fn(*args, **kwargs)
303
+ except ToolError:
304
+ raise
305
+ except _SPEAKABLE as e:
306
+ # `from e` keeps the original in the server log, where the traceback
307
+ # belongs; the client gets the sentence, not the stack.
308
+ raise ToolError(str(e)) from e
309
+ return wrapper
310
+
311
+
272
312
  def _mem(m) -> dict:
273
313
  return m.to_dict(stringify_dates=True) # MCP layer needs plain strings
274
314
 
@@ -292,6 +332,12 @@ def build_server(cfg: Optional[Config] = None):
292
332
  _worker, cfg, backend = wire_server(cfg, embedder)
293
333
  mcp = _mcp("memgres", instructions=_instruction_text())
294
334
 
335
+ def tool(*d_args, **d_kwargs):
336
+ """`mcp.tool()`, but the refusal text survives the trip to the client."""
337
+ def deco(fn):
338
+ return mcp.tool(*d_args, **d_kwargs)(_speaking(fn))
339
+ return deco
340
+
295
341
  import os as _os
296
342
 
297
343
  # The control-plane tools. `auto` registers them wherever there are
@@ -389,7 +435,7 @@ def build_server(cfg: Optional[Config] = None):
389
435
  """
390
436
  return identity.resolve(conn, cfg, token)
391
437
 
392
- @mcp.tool()
438
+ @tool()
393
439
  def memory_write(body: Optional[str] = None, id: Optional[str] = None,
394
440
  at: Optional[str] = None,
395
441
  if_moved: IfMovedArg = "error",
@@ -445,7 +491,7 @@ def build_server(cfg: Optional[Config] = None):
445
491
  reason=reason, valid_at=valid_at, space=space,
446
492
  space_id=space_id))
447
493
 
448
- @mcp.tool()
494
+ @tool()
449
495
  def memory_get(id: Optional[str] = None, at: Optional[str] = None,
450
496
  if_moved: Literal["follow", "error"] = "follow",
451
497
  lines: Optional[str] = None,
@@ -461,6 +507,10 @@ def build_server(cfg: Optional[Config] = None):
461
507
  The answer carries `usage`: how often this memory has surfaced in search
462
508
  (`recalled`) and been fetched (`gets`), and when each last happened.
463
509
 
510
+ It carries NO `source`/`valid_at`, and that is deliberate: provenance
511
+ belongs to a revision, not to the document. Ask `memory_blame` where a
512
+ particular line came from, or `memory_history` where each revision did.
513
+
464
514
  `lines` ("40-80", "5", "1,10-12") returns only part of a long body. The
465
515
  answer is then marked `partial`, carries `total_lines`, and has NO
466
516
  `content_hash` — do not send a slice back as a whole `body`, or
@@ -471,7 +521,7 @@ def build_server(cfg: Optional[Config] = None):
471
521
  if_moved=if_moved, lines=lines,
472
522
  space=space, space_id=space_id))
473
523
 
474
- @mcp.tool()
524
+ @tool()
475
525
  def memory_recall(query: str, k: int = 10,
476
526
  mode: Literal["lexical", "semantic", "hybrid", "auto"] = "auto",
477
527
  match: Optional[Literal["any", "all"]] = None,
@@ -515,7 +565,7 @@ def build_server(cfg: Optional[Config] = None):
515
565
  snippet=snippet, full_body=full_body, bodies=bodies,
516
566
  match_tags=match_tags, space=space, space_id=space_id)]
517
567
 
518
- @mcp.tool()
568
+ @tool()
519
569
  def memory_list(path_prefix: Optional[str] = None,
520
570
  tags: Optional[List[str]] = None, limit: int = 50,
521
571
  offset: int = 0, bodies: bool = False,
@@ -543,7 +593,7 @@ def build_server(cfg: Optional[Config] = None):
543
593
  limit=limit, offset=offset, bodies=bodies,
544
594
  match_tags=match_tags, space=space, space_id=space_id)
545
595
 
546
- @mcp.tool()
596
+ @tool()
547
597
  def memory_tags(prefix: Optional[str] = None, k: int = 50,
548
598
  space: Spaces = None, space_id: Spaces = None,
549
599
  ctx: Context = None) -> List[dict]:
@@ -558,7 +608,7 @@ def build_server(cfg: Optional[Config] = None):
558
608
  return _store(conn).tags(_token(ctx), prefix=prefix, k=k,
559
609
  space=space, space_id=space_id)
560
610
 
561
- @mcp.tool()
611
+ @tool()
562
612
  def memory_links(id: Optional[str] = None, at: Optional[str] = None,
563
613
  direction: Literal["in", "out", "both"] = "both",
564
614
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -579,7 +629,7 @@ def build_server(cfg: Optional[Config] = None):
579
629
  at=at or None, direction=direction,
580
630
  space=space, space_id=space_id)
581
631
 
582
- @mcp.tool()
632
+ @tool()
583
633
  def memory_server_info(ctx: Context = None) -> dict:
584
634
  """The server's version + schema_version and its effective limits and
585
635
  capabilities (write ceilings, embed provider/model/dim, available recall
@@ -589,7 +639,7 @@ def build_server(cfg: Optional[Config] = None):
589
639
  dim = embedder.dim if embedder is not None else None
590
640
  return server_info(cfg, embed_dim=dim)
591
641
 
592
- @mcp.tool()
642
+ @tool()
593
643
  def memory_blame(id: Optional[str] = None, at: Optional[str] = None,
594
644
  grouped: bool = True, lines: Optional[str] = None,
595
645
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -609,7 +659,7 @@ def build_server(cfg: Optional[Config] = None):
609
659
  return s.annotate(tok, id, lines=want, at=at, space=space,
610
660
  space_id=space_id)
611
661
 
612
- @mcp.tool()
662
+ @tool()
613
663
  def memory_history(id: Optional[str] = None, at: Optional[str] = None,
614
664
  space: Optional[str] = None,
615
665
  space_id: Optional[str] = None,
@@ -622,7 +672,7 @@ def build_server(cfg: Optional[Config] = None):
622
672
  return _store(conn).history(_token(ctx), id, at=at,
623
673
  space=space, space_id=space_id)
624
674
 
625
- @mcp.tool()
675
+ @tool()
626
676
  def memory_move(new_path: str, id: Optional[str] = None,
627
677
  at: Optional[str] = None,
628
678
  if_moved: Literal["error", "follow"] = "error",
@@ -638,7 +688,7 @@ def build_server(cfg: Optional[Config] = None):
638
688
  reason=reason, space=space,
639
689
  space_id=space_id))
640
690
 
641
- @mcp.tool()
691
+ @tool()
642
692
  def memory_forget(id: Optional[str] = None, at: Optional[str] = None,
643
693
  space: Optional[str] = None,
644
694
  space_id: Optional[str] = None,
@@ -652,7 +702,7 @@ def build_server(cfg: Optional[Config] = None):
652
702
  _token(ctx), id, at=at, space=space, space_id=space_id)}
653
703
 
654
704
  # ─── identity: spaces & tokens (open/managed modes) ─────────────────────
655
- @mcp.tool()
705
+ @tool()
656
706
  def memory_list_spaces(ctx: Context = None) -> List[dict]:
657
707
  """List the namespaces you can reach — your own plus any shared with you —
658
708
  with each one's id, name, description, permission, and your `alias` for it
@@ -661,7 +711,7 @@ def build_server(cfg: Optional[Config] = None):
661
711
  with pool.connection() as conn, conn.transaction():
662
712
  return identity.list_spaces(conn, _uid(conn, _token(ctx)))
663
713
 
664
- @mcp.tool()
714
+ @tool()
665
715
  def memory_create_space(name: str, description: str = "",
666
716
  instruction: str = "",
667
717
  ctx: Context = None) -> dict:
@@ -675,7 +725,7 @@ def build_server(cfg: Optional[Config] = None):
675
725
  description=description, instruction=instruction)
676
726
  return {"id": nsid, "name": name}
677
727
 
678
- @mcp.tool()
728
+ @tool()
679
729
  def memory_set_alias(alias: str, space_id: str,
680
730
  ctx: Context = None) -> dict:
681
731
  """Give a namespace a name of your own, for when a bare name is ambiguous
@@ -688,7 +738,7 @@ def build_server(cfg: Optional[Config] = None):
688
738
  identity.create_alias(conn, uid, alias, space_id)
689
739
  return {"alias": alias, "space_id": space_id}
690
740
 
691
- @mcp.tool()
741
+ @tool()
692
742
  def memory_drop_alias(alias: str,
693
743
  ctx: Context = None) -> dict:
694
744
  """Remove one of your namespace aliases. The namespace itself is
@@ -698,7 +748,7 @@ def build_server(cfg: Optional[Config] = None):
698
748
  return {"dropped": identity.drop_alias(conn, uid, alias)}
699
749
 
700
750
  if cfg.key_mode == "managed":
701
- @mcp.tool()
751
+ @tool()
702
752
  def memory_enroll(key: EnrollKeyArg, ctx: Context = None) -> dict:
703
753
  """Claim an account with a one-time enrollment key.
704
754
 
@@ -734,7 +784,7 @@ def build_server(cfg: Optional[Config] = None):
734
784
  "tools you can now use are not showing yet.")
735
785
  return out
736
786
 
737
- @mcp.tool()
787
+ @tool()
738
788
  def memory_issue_token(permission: str = "write", space: Optional[str] = None,
739
789
  space_id: Optional[str] = None, label: str = "",
740
790
  expires_days: Optional[int] = None,
@@ -789,7 +839,7 @@ def build_server(cfg: Optional[Config] = None):
789
839
  out.update({"permission": permission, "namespace_id": nsid})
790
840
  return out
791
841
 
792
- @mcp.tool()
842
+ @tool()
793
843
  def memory_request_access(space_id: str, permission: str = "read",
794
844
  ctx: Context = None) -> dict:
795
845
  """Ask the owner of a namespace to let you in.
@@ -804,14 +854,14 @@ def build_server(cfg: Optional[Config] = None):
804
854
  namespace_id=space_id,
805
855
  permission=permission)
806
856
 
807
- @mcp.tool()
857
+ @tool()
808
858
  def memory_list_tokens(ctx: Context = None) -> List[dict]:
809
859
  """List your tokens (metadata only — never the secret)."""
810
860
  with pool.connection() as conn, conn.transaction():
811
861
  out = identity.list_tokens(conn, _admin_uid(conn, _token(ctx)))
812
862
  return _iso(out, *_TOKEN_TIMES)
813
863
 
814
- @mcp.tool()
864
+ @tool()
815
865
  def memory_revoke_token(token_id: str,
816
866
  ctx: Context = None) -> dict:
817
867
  """Revoke one of your tokens by id (kills it immediately)."""
@@ -822,7 +872,7 @@ def build_server(cfg: Optional[Config] = None):
822
872
  raise identity.AuthError("not your token")
823
873
  return {"revoked": identity.revoke_token(conn, token_id)}
824
874
 
825
- @mcp.tool()
875
+ @tool()
826
876
  def memory_whoami(ctx: Context = None) -> dict:
827
877
  """Who you are and what you may do: user id, service role, this
828
878
  credential's permission ceiling and namespace scope, plus the
@@ -834,7 +884,7 @@ def build_server(cfg: Optional[Config] = None):
834
884
  # ─── control plane: provisioning (authorized in `admin`, not here) ───────
835
885
  if admin_surface:
836
886
 
837
- @mcp.tool()
887
+ @tool()
838
888
  def memory_admin_list_users(role: Optional[str] = None, limit: int = 50,
839
889
  offset: int = 0,
840
890
  ctx: Context = None) -> List[dict]:
@@ -846,7 +896,7 @@ def build_server(cfg: Optional[Config] = None):
846
896
  role=role, limit=limit, offset=offset)
847
897
  return _iso(out, "created_at")
848
898
 
849
- @mcp.tool()
899
+ @tool()
850
900
  def memory_admin_create_user(name: str = "", description: str = "",
851
901
  role: str = "user",
852
902
  can_create_namespace: bool = False,
@@ -872,7 +922,7 @@ def build_server(cfg: Optional[Config] = None):
872
922
  department=department, position=position)
873
923
  return {"id": uid}
874
924
 
875
- @mcp.tool()
925
+ @tool()
876
926
  def memory_admin_edit_user(user_id: str,
877
927
  name: Optional[str] = None,
878
928
  description: Optional[str] = None,
@@ -891,7 +941,7 @@ def build_server(cfg: Optional[Config] = None):
891
941
  conn, _principal(conn, _token(ctx)), user_id=user_id,
892
942
  **{k: v for k, v in fields.items() if v is not None})
893
943
 
894
- @mcp.tool()
944
+ @tool()
895
945
  def memory_admin_set_can_create_namespace(user_id: str, allowed: bool,
896
946
  ctx: Context = None) -> dict:
897
947
  """Grant or withdraw a user's right to create namespaces. Without it
@@ -902,7 +952,7 @@ def build_server(cfg: Optional[Config] = None):
902
952
  conn, _principal(conn, _token(ctx)),
903
953
  user_id=user_id, allowed=allowed)
904
954
 
905
- @mcp.tool()
955
+ @tool()
906
956
  def memory_admin_set_role(user_id: str, role: str,
907
957
  ctx: Context = None) -> dict:
908
958
  """Set a user's service role (user | user_manager | superadmin).
@@ -912,7 +962,7 @@ def build_server(cfg: Optional[Config] = None):
912
962
  return admin.set_role(conn, _principal(conn, _token(ctx)),
913
963
  user_id=user_id, role=role)
914
964
 
915
- @mcp.tool()
965
+ @tool()
916
966
  def memory_admin_list_namespaces(owner_user_id: Optional[str] = None,
917
967
  limit: int = 50, offset: int = 0,
918
968
  ctx: Context = None) -> List[dict]:
@@ -925,7 +975,7 @@ def build_server(cfg: Optional[Config] = None):
925
975
  owner_user_id=owner_user_id, limit=limit, offset=offset)
926
976
  return _iso(out, "created_at")
927
977
 
928
- @mcp.tool()
978
+ @tool()
929
979
  def memory_admin_create_namespace(name: str, owner_user_id: str,
930
980
  description: str = "",
931
981
  instruction: str = "",
@@ -940,7 +990,7 @@ def build_server(cfg: Optional[Config] = None):
940
990
  description=description, instruction=instruction)
941
991
  return {"id": nsid}
942
992
 
943
- @mcp.tool()
993
+ @tool()
944
994
  def memory_admin_edit_namespace(space_id: str,
945
995
  description: Optional[str] = None,
946
996
  instruction: Optional[str] = None,
@@ -959,7 +1009,7 @@ def build_server(cfg: Optional[Config] = None):
959
1009
  namespace_id=space_id, description=description,
960
1010
  instruction=instruction, name=name)
961
1011
 
962
- @mcp.tool()
1012
+ @tool()
963
1013
  def memory_admin_count_orphans(ctx: Context = None) -> dict:
964
1014
  """How many memories are stranded in the pre-identity namespace.
965
1015
  `single` mode stores everything under one nameless namespace; after a
@@ -969,7 +1019,7 @@ def build_server(cfg: Optional[Config] = None):
969
1019
  with pool.connection() as conn:
970
1020
  return admin.count_orphans(conn, _principal(conn, _token(ctx)))
971
1021
 
972
- @mcp.tool()
1022
+ @tool()
973
1023
  def memory_admin_adopt_orphans(space_id: str,
974
1024
  ctx: Context = None) -> dict:
975
1025
  """Move every stranded `single`-mode memory into a real namespace.
@@ -981,7 +1031,7 @@ def build_server(cfg: Optional[Config] = None):
981
1031
  conn, _principal(conn, _token(ctx)),
982
1032
  namespace_id=space_id, vectors=backend)
983
1033
 
984
- @mcp.tool()
1034
+ @tool()
985
1035
  def memory_admin_add_member(space_id: str, user_id: str,
986
1036
  permission: str = "read",
987
1037
  ctx: Context = None) -> dict:
@@ -994,7 +1044,7 @@ def build_server(cfg: Optional[Config] = None):
994
1044
  namespace_id=space_id, user_id=user_id,
995
1045
  permission=permission)
996
1046
 
997
- @mcp.tool()
1047
+ @tool()
998
1048
  def memory_admin_remove_member(space_id: str, user_id: str,
999
1049
  ctx: Context = None) -> dict:
1000
1050
  """Take a shared namespace away again. `removed: false` means they
@@ -1009,7 +1059,7 @@ def build_server(cfg: Optional[Config] = None):
1009
1059
  return admin.remove_member(conn, _principal(conn, _token(ctx)),
1010
1060
  namespace_id=space_id, user_id=user_id)
1011
1061
 
1012
- @mcp.tool()
1062
+ @tool()
1013
1063
  def memory_admin_transfer_namespace(
1014
1064
  space_id: str, new_owner_user_id: str,
1015
1065
  keep_previous_owner: Optional[str] = "admin",
@@ -1028,7 +1078,7 @@ def build_server(cfg: Optional[Config] = None):
1028
1078
  new_owner_user_id=new_owner_user_id,
1029
1079
  keep_previous_owner=keep_previous_owner)
1030
1080
 
1031
- @mcp.tool()
1081
+ @tool()
1032
1082
  def memory_admin_list_requests(space_id: str,
1033
1083
  ctx: Context = None) -> List[dict]:
1034
1084
  """Pending requests to join a namespace you administer — who asked,
@@ -1038,7 +1088,7 @@ def build_server(cfg: Optional[Config] = None):
1038
1088
  namespace_id=space_id)
1039
1089
  return _iso(out, "created_at")
1040
1090
 
1041
- @mcp.tool()
1091
+ @tool()
1042
1092
  def memory_admin_decide_access(request_id: str, approve: bool = True,
1043
1093
  expect_permission: Optional[str] = None,
1044
1094
  ctx: Context = None) -> dict:
@@ -1056,7 +1106,7 @@ def build_server(cfg: Optional[Config] = None):
1056
1106
  expect_permission=expect_permission)
1057
1107
  return {"request_id": request_id, "approved": approve}
1058
1108
 
1059
- @mcp.tool()
1109
+ @tool()
1060
1110
  def memory_admin_set_disabled(user_id: str, disabled: bool = True,
1061
1111
  ctx: Context = None) -> dict:
1062
1112
  """Switch an account off, or back on.
@@ -1070,7 +1120,7 @@ def build_server(cfg: Optional[Config] = None):
1070
1120
  return admin.set_disabled(conn, _principal(conn, _token(ctx)),
1071
1121
  user_id=user_id, disabled=disabled)
1072
1122
 
1073
- @mcp.tool()
1123
+ @tool()
1074
1124
  def memory_admin_list_members(space_id: str,
1075
1125
  ctx: Context = None) -> List[dict]:
1076
1126
  """Who can reach a namespace — the owner first, then everyone shared
@@ -1080,7 +1130,7 @@ def build_server(cfg: Optional[Config] = None):
1080
1130
  namespace_id=space_id)
1081
1131
  return _iso(out, "created_at")
1082
1132
 
1083
- @mcp.tool()
1133
+ @tool()
1084
1134
  def memory_admin_issue_token(user_id: str, permission: str = "write",
1085
1135
  space_id: Optional[str] = None,
1086
1136
  label: str = "",
@@ -1112,7 +1162,7 @@ def build_server(cfg: Optional[Config] = None):
1112
1162
  out["warning"] = minted["warning"]
1113
1163
  return out
1114
1164
 
1115
- @mcp.tool()
1165
+ @tool()
1116
1166
  def memory_admin_create_enrollment(
1117
1167
  user_id: str, permission: str = "write",
1118
1168
  space_id: Optional[str] = None, label: str = "",
@@ -1146,7 +1196,7 @@ def build_server(cfg: Optional[Config] = None):
1146
1196
  out["expires_at"] = out["expires_at"].isoformat()
1147
1197
  return admin.deliver_key(out, cfg.token_sink)
1148
1198
 
1149
- @mcp.tool()
1199
+ @tool()
1150
1200
  def memory_admin_list_enrollments(user_id: Optional[str] = None,
1151
1201
  ctx: Context = None) -> List[dict]:
1152
1202
  """Enrollment keys and what became of them — `state` is pending,
@@ -1158,7 +1208,7 @@ def build_server(cfg: Optional[Config] = None):
1158
1208
  user_id=user_id)
1159
1209
  return _iso(out, "created_at", "expires_at", "used_at", "revoked_at")
1160
1210
 
1161
- @mcp.tool()
1211
+ @tool()
1162
1212
  def memory_admin_revoke_enrollment(enrollment_id: str,
1163
1213
  ctx: Context = None) -> dict:
1164
1214
  """Kill an unredeemed enrollment key. False means it was already
@@ -1169,7 +1219,7 @@ def build_server(cfg: Optional[Config] = None):
1169
1219
  conn, _principal(conn, _token(ctx)),
1170
1220
  enrollment_id=enrollment_id)}
1171
1221
 
1172
- @mcp.tool()
1222
+ @tool()
1173
1223
  def memory_admin_list_tokens(user_id: str,
1174
1224
  ctx: Context = None) -> List[dict]:
1175
1225
  """A user's tokens — metadata only, never the secret."""
@@ -1178,7 +1228,7 @@ def build_server(cfg: Optional[Config] = None):
1178
1228
  user_id=user_id)
1179
1229
  return _iso(out, *_TOKEN_TIMES)
1180
1230
 
1181
- @mcp.tool()
1231
+ @tool()
1182
1232
  def memory_admin_revoke_token(token_id: str,
1183
1233
  ctx: Context = None) -> dict:
1184
1234
  """Kill any user's token immediately. False means it was already
@@ -0,0 +1,49 @@
1
+ """What a tree path is — in ONE place.
2
+
3
+ The shape was written down twice before, in the link parser and in whatever SQL
4
+ happened to cast a string to `ltree`, and the two disagreed: a hyphen was legal
5
+ to store and illegal to link, so half the links in a real corpus silently
6
+ vanished (fixed in 0.12.0). This module exists so a third definition does not
7
+ appear.
8
+
9
+ It also gives the check a place to happen BEFORE the value reaches Postgres. An
10
+ unvalidated prefix arrived at the database as `%s::ltree` and came back as
11
+ `psycopg.errors.SyntaxError: ltree syntax error at character 1` — a message
12
+ about our schema, not about their input, and one the caller can do nothing with.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ # Labels of word characters (letters in ANY script, digits, underscore) or
21
+ # hyphens, joined by dots.
22
+ #
23
+ # Two things had to be widened here, each because a real path was being called
24
+ # invalid. The hyphen: Postgres has allowed it in ltree labels since 13, the
25
+ # minimum this project supports. Non-ASCII letters: `ops.тариф` stores and reads
26
+ # perfectly well, and an existing test says so — a check that rejected it would
27
+ # have made a working path unwritable, which is worse than the error it prevents.
28
+ #
29
+ # The parser for `[[links]]` uses the SAME alphabet on purpose. When it was
30
+ # narrower, every hyphenated link was dropped as prose; a narrower rule here
31
+ # would recreate that, only for Cyrillic.
32
+ PATH_RE = re.compile(r"^[\w-]+(\.[\w-]+)*$", re.UNICODE)
33
+
34
+
35
+ def is_path(value: Optional[str]) -> bool:
36
+ return bool(value) and bool(PATH_RE.match(value))
37
+
38
+
39
+ def check_path(value: Optional[str], field: str = "path") -> Optional[str]:
40
+ """Return `value` unchanged, or raise ValueError naming the field and the
41
+ shape. Empty/None passes — "no path given" is not a malformed one."""
42
+ if value is None or value == "":
43
+ return value
44
+ if not PATH_RE.match(value):
45
+ raise ValueError(
46
+ f"`{field}` is not a tree path: {value!r}. A path is labels of "
47
+ f"letters (any script), digits, `_` or `-` joined by dots, like "
48
+ f"`ops.memory.onboarding` — no spaces, slashes or leading dots.")
49
+ return value
@@ -35,6 +35,7 @@ from . import identity
35
35
  from .config import Config
36
36
  from .delimiters import write_warnings
37
37
  from .lines import parse_line_spec
38
+ from .paths import check_path
38
39
  from .diffing import DiffConflict, apply_diff, byte_len, content_hash, make_diff
39
40
  from .embeddings import Embedder, get_embedder
40
41
  from .links import parse_links, rewrite_targets
@@ -250,12 +251,21 @@ class Memory:
250
251
  # Provenance of the REVISION this call just wrote — never of the memory,
251
252
  # which has no single source: `source`/`reason`/`valid_at` live on the
252
253
  # history row because different edits speak to different origins and dates.
253
- # So these are set by `write` and stay None on a read, where the question
254
- # belongs to `history`/`blame`. Echoed back because a required field that the
255
- # answer does not confirm is a field whose absence nobody notices: four edits
256
- # in a row went out with an empty `source` and every reply looked fine.
254
+ # Echoed back on a write because a required field the answer does not confirm
255
+ # is a field whose absence nobody notices: four edits in a row went out with
256
+ # an empty `source` and every reply looked fine.
257
+ #
258
+ # `is_write` decides whether they are SERIALIZED at all, and that is the
259
+ # point: a read used to emit `"source": null`, which does not read as "this
260
+ # memory has no such field" but as "the value was there and is now gone".
261
+ # Two readers in a row concluded the field was being silently dropped and
262
+ # went looking for the bug — one of them spent a day on it and wrote the
263
+ # workaround into memory. So on a read the keys are ABSENT, and the question
264
+ # they answer belongs to `history`/`blame`, where the answer is per-revision
265
+ # and per-line rather than one value pretending to describe the document.
257
266
  source: Optional[str] = None
258
267
  valid_at: object = None
268
+ is_write: bool = False
259
269
 
260
270
  def to_dict(self, *, stringify_dates: bool = False) -> dict:
261
271
  """Serialize for an API layer. ``stringify_dates`` str()-coerces the
@@ -263,9 +273,8 @@ class Memory:
263
273
  datetimes itself, so the HTTP layer passes them through raw)."""
264
274
  def d(v):
265
275
  return (str(v) if v is not None else None) if stringify_dates else v
266
- return {"id": self.id, "content_hash": self.content_hash, "body": self.body,
276
+ out = {"id": self.id, "content_hash": self.content_hash, "body": self.body,
267
277
  "title": self.title, "tags": self.tags, "path": self.path,
268
- "source": self.source, "valid_at": d(self.valid_at),
269
278
  "seq": self.seq, "created_at": d(self.created_at),
270
279
  "updated_at": d(self.updated_at), "expires_at": d(self.expires_at),
271
280
  "created": self.created, "moved_from": self.moved_from,
@@ -275,6 +284,12 @@ class Memory:
275
284
  "last_recall_at": d(self.usage["last_recall_at"]),
276
285
  "last_get_at": d(self.usage["last_get_at"])}
277
286
  if self.usage else None)}
287
+ if self.is_write:
288
+ # Only here: the provenance of the revision just written, confirmed
289
+ # back to its author. A read carries no such keys (see `is_write`).
290
+ out["source"] = self.source
291
+ out["valid_at"] = d(self.valid_at)
292
+ return out
278
293
 
279
294
 
280
295
  def _sha(text: str) -> str:
@@ -625,6 +640,7 @@ class Store:
625
640
  # stored one way and filtered another.
626
641
  tags = normalize_tags(tags)
627
642
  valid_at = _as_date(valid_at)
643
+ check_path(path, "path")
628
644
  if id is None and at is None:
629
645
  self._check_path_free(ns, path, if_moved)
630
646
  m = self._create(ns, author, body, path, tags, source, reason,
@@ -641,7 +657,8 @@ class Store:
641
657
  # diff can introduce the stray tag just as a whole body can.
642
658
  m.warnings = write_warnings(m.body)
643
659
  # The revision's own provenance, straight back to whoever wrote it.
644
- m.source, m.valid_at = source, valid_at
660
+ # `is_write` is what puts these on the wire; a read leaves them off.
661
+ m.source, m.valid_at, m.is_write = source, valid_at, True
645
662
  return m
646
663
 
647
664
  def _check_path_free(self, ns: str, path: Optional[str],