memgres 0.12.2__tar.gz → 0.12.3__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.2 → memgres-0.12.3}/PKG-INFO +1 -1
  2. {memgres-0.12.2 → memgres-0.12.3}/memgres/_version.py +1 -1
  3. {memgres-0.12.2 → memgres-0.12.3}/memgres/store.py +85 -5
  4. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/PKG-INFO +1 -1
  5. {memgres-0.12.2 → memgres-0.12.3}/tests/test_require_title.py +34 -1
  6. {memgres-0.12.2 → memgres-0.12.3}/tests/test_store_integration.py +32 -0
  7. {memgres-0.12.2 → memgres-0.12.3}/LICENSE +0 -0
  8. {memgres-0.12.2 → memgres-0.12.3}/README.md +0 -0
  9. {memgres-0.12.2 → memgres-0.12.3}/memgres/__init__.py +0 -0
  10. {memgres-0.12.2 → memgres-0.12.3}/memgres/admin.py +0 -0
  11. {memgres-0.12.2 → memgres-0.12.3}/memgres/admin_cli.py +0 -0
  12. {memgres-0.12.2 → memgres-0.12.3}/memgres/blame.py +0 -0
  13. {memgres-0.12.2 → memgres-0.12.3}/memgres/bootstrap.py +0 -0
  14. {memgres-0.12.2 → memgres-0.12.3}/memgres/config.py +0 -0
  15. {memgres-0.12.2 → memgres-0.12.3}/memgres/delimiters.py +0 -0
  16. {memgres-0.12.2 → memgres-0.12.3}/memgres/diffing.py +0 -0
  17. {memgres-0.12.2 → memgres-0.12.3}/memgres/embed_worker.py +0 -0
  18. {memgres-0.12.2 → memgres-0.12.3}/memgres/embeddings.py +0 -0
  19. {memgres-0.12.2 → memgres-0.12.3}/memgres/healthcheck.py +0 -0
  20. {memgres-0.12.2 → memgres-0.12.3}/memgres/identity.py +0 -0
  21. {memgres-0.12.2 → memgres-0.12.3}/memgres/indexing.py +0 -0
  22. {memgres-0.12.2 → memgres-0.12.3}/memgres/info.py +0 -0
  23. {memgres-0.12.2 → memgres-0.12.3}/memgres/lines.py +0 -0
  24. {memgres-0.12.2 → memgres-0.12.3}/memgres/links.py +0 -0
  25. {memgres-0.12.2 → memgres-0.12.3}/memgres/mcp_server.py +0 -0
  26. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0001_core.sql +0 -0
  27. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0002_identity.sql +0 -0
  28. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0003_history_author.sql +0 -0
  29. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0004_title.sql +0 -0
  30. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0005_chunk_index.sql +0 -0
  31. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0006_reader_floor.sql +0 -0
  32. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0007_embed_retry.sql +0 -0
  33. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0008_service_roles.sql +0 -0
  34. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  35. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0010_namespace_alias.sql +0 -0
  36. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  37. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0012_user_profile.sql +0 -0
  38. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0013_hash_version.sql +0 -0
  39. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  40. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0015_normalize_tags.sql +0 -0
  41. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0016_valid_at.sql +0 -0
  42. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0017_memory_link.sql +0 -0
  43. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0018_links_built.sql +0 -0
  44. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0019_memory_usage.sql +0 -0
  45. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  46. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0021_enrollment_key.sql +0 -0
  47. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0022_user_disabled.sql +0 -0
  48. {memgres-0.12.2 → memgres-0.12.3}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
  49. {memgres-0.12.2 → memgres-0.12.3}/memgres/paths.py +0 -0
  50. {memgres-0.12.2 → memgres-0.12.3}/memgres/periodic.py +0 -0
  51. {memgres-0.12.2 → memgres-0.12.3}/memgres/reembed.py +0 -0
  52. {memgres-0.12.2 → memgres-0.12.3}/memgres/relink.py +0 -0
  53. {memgres-0.12.2 → memgres-0.12.3}/memgres/schema.py +0 -0
  54. {memgres-0.12.2 → memgres-0.12.3}/memgres/search.py +0 -0
  55. {memgres-0.12.2 → memgres-0.12.3}/memgres/segments.py +0 -0
  56. {memgres-0.12.2 → memgres-0.12.3}/memgres/server.py +0 -0
  57. {memgres-0.12.2 → memgres-0.12.3}/memgres/tags.py +0 -0
  58. {memgres-0.12.2 → memgres-0.12.3}/memgres/token_cli.py +0 -0
  59. {memgres-0.12.2 → memgres-0.12.3}/memgres/vector/__init__.py +0 -0
  60. {memgres-0.12.2 → memgres-0.12.3}/memgres/vector/base.py +0 -0
  61. {memgres-0.12.2 → memgres-0.12.3}/memgres/vector/pgvector.py +0 -0
  62. {memgres-0.12.2 → memgres-0.12.3}/memgres/vector/qdrant.py +0 -0
  63. {memgres-0.12.2 → memgres-0.12.3}/memgres/worker.py +0 -0
  64. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/SOURCES.txt +0 -0
  65. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/dependency_links.txt +0 -0
  66. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/entry_points.txt +0 -0
  67. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/requires.txt +0 -0
  68. {memgres-0.12.2 → memgres-0.12.3}/memgres.egg-info/top_level.txt +0 -0
  69. {memgres-0.12.2 → memgres-0.12.3}/pyproject.toml +0 -0
  70. {memgres-0.12.2 → memgres-0.12.3}/setup.cfg +0 -0
  71. {memgres-0.12.2 → memgres-0.12.3}/tests/test_admin_two_way.py +0 -0
  72. {memgres-0.12.2 → memgres-0.12.3}/tests/test_blame_integration.py +0 -0
  73. {memgres-0.12.2 → memgres-0.12.3}/tests/test_chunk_index.py +0 -0
  74. {memgres-0.12.2 → memgres-0.12.3}/tests/test_claim_and_reembed.py +0 -0
  75. {memgres-0.12.2 → memgres-0.12.3}/tests/test_config.py +0 -0
  76. {memgres-0.12.2 → memgres-0.12.3}/tests/test_diffing.py +0 -0
  77. {memgres-0.12.2 → memgres-0.12.3}/tests/test_embed_worker.py +0 -0
  78. {memgres-0.12.2 → memgres-0.12.3}/tests/test_embeddings.py +0 -0
  79. {memgres-0.12.2 → memgres-0.12.3}/tests/test_enrollment.py +0 -0
  80. {memgres-0.12.2 → memgres-0.12.3}/tests/test_healthcheck.py +0 -0
  81. {memgres-0.12.2 → memgres-0.12.3}/tests/test_identity_integration.py +0 -0
  82. {memgres-0.12.2 → memgres-0.12.3}/tests/test_lexical_match.py +0 -0
  83. {memgres-0.12.2 → memgres-0.12.3}/tests/test_limits.py +0 -0
  84. {memgres-0.12.2 → memgres-0.12.3}/tests/test_links.py +0 -0
  85. {memgres-0.12.2 → memgres-0.12.3}/tests/test_list.py +0 -0
  86. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_admin_tools.py +0 -0
  87. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_error_messages.py +0 -0
  88. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_http_transport.py +0 -0
  89. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_instructions.py +0 -0
  90. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_recall_schema.py +0 -0
  91. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_tool_visibility.py +0 -0
  92. {memgres-0.12.2 → memgres-0.12.3}/tests/test_mcp_tool_visibility_http.py +0 -0
  93. {memgres-0.12.2 → memgres-0.12.3}/tests/test_migration_upgrade.py +0 -0
  94. {memgres-0.12.2 → memgres-0.12.3}/tests/test_multi_space_search.py +0 -0
  95. {memgres-0.12.2 → memgres-0.12.3}/tests/test_path_addressing.py +0 -0
  96. {memgres-0.12.2 → memgres-0.12.3}/tests/test_qdrant_ca.py +0 -0
  97. {memgres-0.12.2 → memgres-0.12.3}/tests/test_qdrant_integration.py +0 -0
  98. {memgres-0.12.2 → memgres-0.12.3}/tests/test_replace_build.py +0 -0
  99. {memgres-0.12.2 → memgres-0.12.3}/tests/test_required_fields.py +0 -0
  100. {memgres-0.12.2 → memgres-0.12.3}/tests/test_retention.py +0 -0
  101. {memgres-0.12.2 → memgres-0.12.3}/tests/test_roles_bootstrap.py +0 -0
  102. {memgres-0.12.2 → memgres-0.12.3}/tests/test_search_integration.py +0 -0
  103. {memgres-0.12.2 → memgres-0.12.3}/tests/test_security_followups.py +0 -0
  104. {memgres-0.12.2 → memgres-0.12.3}/tests/test_security_integration.py +0 -0
  105. {memgres-0.12.2 → memgres-0.12.3}/tests/test_segments.py +0 -0
  106. {memgres-0.12.2 → memgres-0.12.3}/tests/test_segments_store.py +0 -0
  107. {memgres-0.12.2 → memgres-0.12.3}/tests/test_server_info.py +0 -0
  108. {memgres-0.12.2 → memgres-0.12.3}/tests/test_server_integration.py +0 -0
  109. {memgres-0.12.2 → memgres-0.12.3}/tests/test_snippets.py +0 -0
  110. {memgres-0.12.2 → memgres-0.12.3}/tests/test_tags.py +0 -0
  111. {memgres-0.12.2 → memgres-0.12.3}/tests/test_token_sink.py +0 -0
  112. {memgres-0.12.2 → memgres-0.12.3}/tests/test_usage.py +0 -0
  113. {memgres-0.12.2 → memgres-0.12.3}/tests/test_valid_at.py +0 -0
  114. {memgres-0.12.2 → memgres-0.12.3}/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.2
3
+ Version: 0.12.3
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.2"
11
+ __version__ = "0.12.3"
@@ -451,6 +451,57 @@ def _slice_lines(m: "Memory", spec: str) -> "Memory":
451
451
  return m
452
452
 
453
453
 
454
+
455
+ # How much body text a "why did my replace miss" hint may quote, per side.
456
+ _HINT_CONTEXT = 48
457
+
458
+
459
+ def _why_replace_missed(body: str, old: str) -> str:
460
+ """Say WHY a substring edit found nothing — the refusal alone sends the author
461
+ to re-read the whole record, when the useful answer is almost always "your
462
+ quote is not what the body says, here is what it says".
463
+
464
+ Three causes, in the order they actually happen:
465
+
466
+ 1. **Whitespace.** Bodies are hard-wrapped, so a phrase that reads as one line
467
+ on screen contains a newline. Retyped as a space, it can never match.
468
+ 2. **Case.**
469
+ 3. **Everything else** — then the most useful thing is the point where the
470
+ quote stops agreeing with the body, and what the body has instead.
471
+
472
+ The quote itself is usually reconstructed by eye rather than copied, which is
473
+ why the hint ends by naming the one reliable source: `memory_get`. A recall
474
+ snippet is a slice of the body (`lines` says which) EXCEPT on the ts_headline
475
+ path, where Postgres rebuilds the text from tokens and `lines` is null — that
476
+ one is not quotable at all.
477
+ """
478
+ squashed_old = " ".join(old.split())
479
+ if squashed_old and squashed_old in " ".join(body.split()):
480
+ return (" — the text IS in the body, but the whitespace differs: bodies "
481
+ "wrap, so a line break in the record reads as a space on screen. "
482
+ "Copy the line from `memory_get`, not from a recall snippet")
483
+ if old.lower() in body.lower():
484
+ return (" — it is there in a different case. Copy it from `memory_get`, "
485
+ "not from a recall snippet")
486
+ # Longest prefix of `old` the body still agrees with: the point of divergence
487
+ # is what the author needs to see. Binary search — bodies are capped, but a
488
+ # linear scan would re-scan the whole body once per character.
489
+ lo, hi = 0, len(old)
490
+ while lo < hi:
491
+ mid = (lo + hi + 1) // 2
492
+ if old[:mid] in body:
493
+ lo = mid
494
+ else:
495
+ hi = mid - 1
496
+ if lo == 0:
497
+ return (" — not one character of it is in the body; wrong memory, or the "
498
+ "quote was written from memory rather than copied from `memory_get`")
499
+ at = body.find(old[:lo])
500
+ reads = body[at:at + lo + _HINT_CONTEXT]
501
+ return (f" — the first {lo} chars match, then they part: the body reads "
502
+ f"{reads!r}. Copy from `memory_get`, not from a recall snippet")
503
+
504
+
454
505
  class Store:
455
506
  def __init__(self, cfg: Config, embedder: Optional[Embedder] = None,
456
507
  conn: Optional["psycopg.Connection"] = None,
@@ -773,11 +824,38 @@ class Store:
773
824
  f"this deployment requires `{field}` on every write that stores "
774
825
  f"content — {help_text}")
775
826
 
827
+ # How much of the title to quote either side of the cut. Enough to recognise
828
+ # the words, short enough that a pathological title cannot turn the refusal
829
+ # into a wall of text.
830
+ _TITLE_CUT_CONTEXT = 40
831
+
776
832
  def _check_title_size(self, title: Optional[str]):
777
- if title is not None and byte_len(title) > self.cfg.max_title_bytes:
778
- raise TooLarge(
779
- f"title is {byte_len(title)}B > MEMGRES_MAX_TITLE_BYTES "
780
- f"{self.cfg.max_title_bytes}")
833
+ """Refuse an oversized title, and SHOW WHERE IT STOPS FITTING.
834
+
835
+ The ceiling is in bytes because that is what storage and the wire count,
836
+ but the author is writing characters — and in UTF-8 the exchange rate
837
+ depends on the script: Latin runs ~1 B/char, Cyrillic ~2, so the same 256
838
+ B is ~256 characters in one language and ~148 in another. A refusal that
839
+ names only bytes leaves the author trimming blind, one attempt at a time
840
+ (observed: four rejected writes in a row on one record). So the message
841
+ carries the character counts and quotes the cut itself, marked with ✂ —
842
+ what survives on the left, what has to go on the right."""
843
+ if title is None:
844
+ return
845
+ size, cap = byte_len(title), self.cfg.max_title_bytes
846
+ if size <= cap:
847
+ return
848
+ # Truncating BYTES can land mid-character; decoding with "ignore" drops
849
+ # that partial character, which is exactly the last one that does NOT fit.
850
+ fits = title.encode("utf-8")[:cap].decode("utf-8", "ignore")
851
+ over = title[len(fits):]
852
+ ctx = self._TITLE_CUT_CONTEXT
853
+ left = ("…" if len(fits) > ctx else "") + fits[-ctx:]
854
+ right = over[:ctx] + ("…" if len(over) > ctx else "")
855
+ raise TooLarge(
856
+ f"title is {size}B > MEMGRES_MAX_TITLE_BYTES {cap}: "
857
+ f"{len(title)} chars, {len(fits)} fit, drop {len(over)} — "
858
+ f"{left}[✂]{right}")
781
859
 
782
860
  def _create(self, ns, author, body, path, tags, source, reason,
783
861
  title=None, valid_at=None) -> Memory:
@@ -868,7 +946,9 @@ class Store:
868
946
  raise Conflict(f"stale replace: base {base_hash[:12]} != current {cur_hash[:12]}")
869
947
  count = cur_body.count(old)
870
948
  if count == 0:
871
- raise ReplaceNotFound(f"replace text not found in body: {old[:60]!r}")
949
+ raise ReplaceNotFound(
950
+ f"replace text not found in body: {old[:60]!r}"
951
+ f"{_why_replace_missed(cur_body, old)}")
872
952
  if count > 1 and not replace_all:
873
953
  raise AmbiguousReplace(
874
954
  f"replace text occurs {count}× — pass replace_all, or add "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.12.2
3
+ Version: 0.12.3
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
@@ -26,7 +26,7 @@ psycopg = pytest.importorskip("psycopg")
26
26
 
27
27
  from memgres.config import load # noqa: E402
28
28
  from memgres.schema import migrate # noqa: E402
29
- from memgres.store import MissingTitle, Store # noqa: E402
29
+ from memgres.store import MissingTitle, Store, TooLarge # noqa: E402
30
30
 
31
31
  DSN = os.environ.get("MEMGRES_TEST_DSN",
32
32
  "postgresql://memgres:memgres@localhost:55432/memgres")
@@ -181,3 +181,36 @@ def test_a_create_with_no_body_blames_the_body(conn, monkeypatch):
181
181
  s = _store(conn, monkeypatch, True)
182
182
  with pytest.raises(ValueError, match="needs a body"):
183
183
  s.write(path="a.b")
184
+
185
+
186
+ # ─── the size ceiling explains itself ────────────────────────────────────────
187
+ def test_an_oversized_title_shows_where_it_stops_fitting(conn, monkeypatch):
188
+ """The ceiling counts bytes, the author counts characters, and in UTF-8 the
189
+ rate depends on the script — so a refusal naming only bytes leaves them
190
+ trimming blind. The message quotes the cut: what fits, what has to go."""
191
+ monkeypatch.setenv("MEMGRES_MAX_TITLE_BYTES", "40")
192
+ s = _store(conn, monkeypatch, True)
193
+ title = "Лимит заголовка меряется в байтах, а не в символах"
194
+ with pytest.raises(TooLarge) as e:
195
+ s.write(body="one", path="a.b", title=title)
196
+ msg = str(e.value)
197
+ # counts in BOTH units, and how much to drop
198
+ assert f"{len(title.encode())}B" in msg and "40" in msg
199
+ assert f"{len(title)} chars" in msg
200
+ # the cut is quoted, and the two sides really are the two sides
201
+ assert "[✂]" in msg
202
+ fits = title.encode()[:40].decode("utf-8", "ignore")
203
+ assert f"{len(fits)} fit" in msg
204
+ assert f"drop {len(title) - len(fits)}" in msg
205
+ assert fits[-10:] + "[✂]" in msg # left of the cut survives
206
+ assert "[✂]" + title[len(fits):][:10] in msg # right of it is what to drop
207
+
208
+
209
+ def test_a_title_that_only_just_fits_is_accepted(conn, monkeypatch):
210
+ """The boundary itself is not an error — byte_len == cap must pass, or the
211
+ limit silently means one byte less than it says."""
212
+ monkeypatch.setenv("MEMGRES_MAX_TITLE_BYTES", "40")
213
+ s = _store(conn, monkeypatch, True)
214
+ title = "я" * 20 # exactly 40 bytes
215
+ assert len(title.encode()) == 40
216
+ assert s.write(body="one", path="a.b", title=title).title == title
@@ -230,6 +230,38 @@ def test_replace_not_found_leaves_record_untouched(store):
230
230
  assert again.body == "alpha\nbeta\n" and again.seq == 1
231
231
 
232
232
 
233
+ def test_a_missed_replace_says_the_quote_crossed_a_line_break(store):
234
+ """The commonest miss: the body wraps, the phrase reads as one line on
235
+ screen, and the quote comes back with a space where the record has \n. The
236
+ refusal has to name that, or the author goes re-reading the whole record."""
237
+ store.write(body="хвост ниши по деньгам — перепродажа\nExa через x402\n",
238
+ path="a.b", title="A")
239
+ with pytest.raises(ReplaceNotFound) as e:
240
+ store.write(at="a.b", replace=("перепродажа Exa", "перепродажа Exa Search"))
241
+ msg = str(e.value)
242
+ assert "whitespace differs" in msg and "memory_get" in msg
243
+
244
+
245
+ def test_a_missed_replace_shows_where_the_quote_parts_from_the_body(store):
246
+ """When it is not whitespace, the useful answer is the point of divergence
247
+ and what the body actually says there."""
248
+ store.write(body="верх ниши — **«rail dead»** значит другое\n",
249
+ path="a.c", title="A")
250
+ with pytest.raises(ReplaceNotFound) as e:
251
+ store.write(at="a.c", replace=("верх ниши — «rail dead»", "x"))
252
+ msg = str(e.value)
253
+ assert "chars match" in msg and "«rail dead»**" in msg
254
+
255
+
256
+ def test_a_replace_of_something_absent_says_so_plainly(store):
257
+ """No prefix at all is a different mistake — wrong record, or a quote written
258
+ from memory — and must not be dressed up as a near miss."""
259
+ store.write(body="alpha\nbeta\n", path="a.d", title="A")
260
+ with pytest.raises(ReplaceNotFound) as e:
261
+ store.write(at="a.d", replace=("zzz", "x"))
262
+ assert "not one character" in str(e.value)
263
+
264
+
233
265
  def test_replace_ambiguous_requires_all_or_context(store):
234
266
  m = store.write(body="x x x\n")
235
267
  with pytest.raises(AmbiguousReplace):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes