memgres 0.4.0__tar.gz → 0.4.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 (65) hide show
  1. {memgres-0.4.0 → memgres-0.4.1}/PKG-INFO +1 -1
  2. {memgres-0.4.0 → memgres-0.4.1}/memgres/_version.py +1 -1
  3. {memgres-0.4.0 → memgres-0.4.1}/memgres/mcp_server.py +2 -4
  4. {memgres-0.4.0 → memgres-0.4.1}/memgres/server.py +5 -5
  5. {memgres-0.4.0 → memgres-0.4.1}/memgres/store.py +20 -0
  6. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/PKG-INFO +1 -1
  7. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/SOURCES.txt +1 -0
  8. memgres-0.4.1/tests/test_replace_build.py +40 -0
  9. {memgres-0.4.0 → memgres-0.4.1}/tests/test_server_integration.py +19 -0
  10. {memgres-0.4.0 → memgres-0.4.1}/LICENSE +0 -0
  11. {memgres-0.4.0 → memgres-0.4.1}/README.md +0 -0
  12. {memgres-0.4.0 → memgres-0.4.1}/memgres/__init__.py +0 -0
  13. {memgres-0.4.0 → memgres-0.4.1}/memgres/blame.py +0 -0
  14. {memgres-0.4.0 → memgres-0.4.1}/memgres/config.py +0 -0
  15. {memgres-0.4.0 → memgres-0.4.1}/memgres/diffing.py +0 -0
  16. {memgres-0.4.0 → memgres-0.4.1}/memgres/embed_worker.py +0 -0
  17. {memgres-0.4.0 → memgres-0.4.1}/memgres/embeddings.py +0 -0
  18. {memgres-0.4.0 → memgres-0.4.1}/memgres/identity.py +0 -0
  19. {memgres-0.4.0 → memgres-0.4.1}/memgres/indexing.py +0 -0
  20. {memgres-0.4.0 → memgres-0.4.1}/memgres/info.py +0 -0
  21. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0001_core.sql +0 -0
  22. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0002_identity.sql +0 -0
  23. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0003_history_author.sql +0 -0
  24. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0004_title.sql +0 -0
  25. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0005_chunk_index.sql +0 -0
  26. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0006_reader_floor.sql +0 -0
  27. {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0007_embed_retry.sql +0 -0
  28. {memgres-0.4.0 → memgres-0.4.1}/memgres/reembed.py +0 -0
  29. {memgres-0.4.0 → memgres-0.4.1}/memgres/schema.py +0 -0
  30. {memgres-0.4.0 → memgres-0.4.1}/memgres/search.py +0 -0
  31. {memgres-0.4.0 → memgres-0.4.1}/memgres/segments.py +0 -0
  32. {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/__init__.py +0 -0
  33. {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/base.py +0 -0
  34. {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/pgvector.py +0 -0
  35. {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/qdrant.py +0 -0
  36. {memgres-0.4.0 → memgres-0.4.1}/memgres/worker.py +0 -0
  37. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/dependency_links.txt +0 -0
  38. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/entry_points.txt +0 -0
  39. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/requires.txt +0 -0
  40. {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/top_level.txt +0 -0
  41. {memgres-0.4.0 → memgres-0.4.1}/pyproject.toml +0 -0
  42. {memgres-0.4.0 → memgres-0.4.1}/setup.cfg +0 -0
  43. {memgres-0.4.0 → memgres-0.4.1}/tests/test_blame_integration.py +0 -0
  44. {memgres-0.4.0 → memgres-0.4.1}/tests/test_chunk_index.py +0 -0
  45. {memgres-0.4.0 → memgres-0.4.1}/tests/test_claim_and_reembed.py +0 -0
  46. {memgres-0.4.0 → memgres-0.4.1}/tests/test_config.py +0 -0
  47. {memgres-0.4.0 → memgres-0.4.1}/tests/test_diffing.py +0 -0
  48. {memgres-0.4.0 → memgres-0.4.1}/tests/test_embed_worker.py +0 -0
  49. {memgres-0.4.0 → memgres-0.4.1}/tests/test_embeddings.py +0 -0
  50. {memgres-0.4.0 → memgres-0.4.1}/tests/test_identity_integration.py +0 -0
  51. {memgres-0.4.0 → memgres-0.4.1}/tests/test_lexical_match.py +0 -0
  52. {memgres-0.4.0 → memgres-0.4.1}/tests/test_limits.py +0 -0
  53. {memgres-0.4.0 → memgres-0.4.1}/tests/test_list.py +0 -0
  54. {memgres-0.4.0 → memgres-0.4.1}/tests/test_mcp_instructions.py +0 -0
  55. {memgres-0.4.0 → memgres-0.4.1}/tests/test_mcp_recall_schema.py +0 -0
  56. {memgres-0.4.0 → memgres-0.4.1}/tests/test_migration_upgrade.py +0 -0
  57. {memgres-0.4.0 → memgres-0.4.1}/tests/test_qdrant_ca.py +0 -0
  58. {memgres-0.4.0 → memgres-0.4.1}/tests/test_qdrant_integration.py +0 -0
  59. {memgres-0.4.0 → memgres-0.4.1}/tests/test_search_integration.py +0 -0
  60. {memgres-0.4.0 → memgres-0.4.1}/tests/test_security_integration.py +0 -0
  61. {memgres-0.4.0 → memgres-0.4.1}/tests/test_segments.py +0 -0
  62. {memgres-0.4.0 → memgres-0.4.1}/tests/test_segments_store.py +0 -0
  63. {memgres-0.4.0 → memgres-0.4.1}/tests/test_server_info.py +0 -0
  64. {memgres-0.4.0 → memgres-0.4.1}/tests/test_snippets.py +0 -0
  65. {memgres-0.4.0 → memgres-0.4.1}/tests/test_store_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -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.4.0"
11
+ __version__ = "0.4.1"
@@ -36,7 +36,7 @@ from . import identity
36
36
  from .config import Config, load
37
37
  from .embeddings import get_embedder
38
38
  from .schema import migrate
39
- from .store import Store
39
+ from .store import Store, build_replace
40
40
 
41
41
 
42
42
  # The MCP `initialize` response carries a server-side `instructions` string; a
@@ -180,9 +180,7 @@ def build_server(cfg: Optional[Config] = None):
180
180
  curated caption (set whole, searchable via `memory_find`); `source`/`reason`
181
181
  record provenance. `space` picks one of your namespaces by name (`space_id`
182
182
  for a shared one); omit both to use your default."""
183
- replace = None
184
- if replace_old is not None or replace_new is not None:
185
- replace = (replace_old or "", replace_new or "")
183
+ replace = build_replace(replace_old, replace_new)
186
184
  with pool.connection() as conn:
187
185
  return _mem(_store(conn).write(
188
186
  _token(ctx, token), id=id or None, body=body, diff=diff,
@@ -31,7 +31,7 @@ from .diffing import DiffConflict
31
31
  from .embeddings import get_embedder
32
32
  from .identity import SpaceNotFound
33
33
  from .schema import migrate
34
- from .store import Conflict, NoParent, NotFound, Store, TooLarge
34
+ from .store import Conflict, NoParent, NotFound, Store, TooLarge, build_replace
35
35
 
36
36
 
37
37
  def _parse_lines(spec: Optional[str]) -> Optional[List[int]]:
@@ -174,12 +174,12 @@ def create_app(cfg: Optional[Config] = None):
174
174
  @app.patch("/memories/{mid}")
175
175
  def edit(mid: str, req: EditBody, tok: Optional[str] = Depends(token)):
176
176
  with pool.connection() as conn:
177
- replace = None
178
- if req.replace_old is not None or req.replace_new is not None:
179
- replace = (req.replace_old or "", req.replace_new or "")
177
+ # build_replace runs inside _guard so a lone replace_old/new surfaces
178
+ # as its ValueError -> 422, not a silent delete or an uncaught 500.
180
179
  m = _guard(lambda: _store(conn).write(
181
180
  tok, id=mid, body=req.body, diff=req.diff, base_hash=req.base_hash,
182
- replace=replace, replace_all=req.replace_all,
181
+ replace=build_replace(req.replace_old, req.replace_new),
182
+ replace_all=req.replace_all,
183
183
  path=req.path, tags=req.tags, title=req.title,
184
184
  source=req.source, reason=req.reason,
185
185
  ttl_days=req.ttl_days, space=req.space, space_id=req.space_id))
@@ -53,6 +53,26 @@ class AmbiguousReplace(ValueError):
53
53
  """A `replace`'s `old` occurs more than once and `replace_all` wasn't set."""
54
54
 
55
55
 
56
+ def build_replace(replace_old: Optional[str], replace_new: Optional[str]):
57
+ """Assemble the substring-replace ``(old, new)`` tuple from the two optional
58
+ request fields — the one place both the HTTP and MCP layers turn them into the
59
+ ``replace`` argument, so the rule lives once.
60
+
61
+ It distinguishes *not provided* (``None``) from an explicit empty string. Both
62
+ omitted → ``None`` (no replace). Exactly one provided → ``ValueError``: a lone
63
+ ``replace_old`` must not be coerced to ``(old, "")`` and **silently delete**
64
+ the matched text (the regression this guards), and a lone ``replace_new`` has
65
+ no anchor. Both provided → ``(old, new)``; ``new`` may be ``""`` to delete on
66
+ purpose, which is now the *only* way to get a deletion.
67
+ """
68
+ if replace_old is None and replace_new is None:
69
+ return None
70
+ if replace_old is None or replace_new is None:
71
+ raise ValueError("replace needs both replace_old and replace_new "
72
+ "(pass replace_new='' to delete the matched text)")
73
+ return (replace_old, replace_new)
74
+
75
+
56
76
  @dataclass
57
77
  class Memory:
58
78
  id: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -52,6 +52,7 @@ tests/test_mcp_recall_schema.py
52
52
  tests/test_migration_upgrade.py
53
53
  tests/test_qdrant_ca.py
54
54
  tests/test_qdrant_integration.py
55
+ tests/test_replace_build.py
55
56
  tests/test_search_integration.py
56
57
  tests/test_security_integration.py
57
58
  tests/test_segments.py
@@ -0,0 +1,40 @@
1
+ """build_replace(): a lone replace_old/replace_new is an error, never a silent
2
+ delete. Regression guard for meta.memgres.replace_new_dropped — a missing
3
+ replace_new used to be coerced to "" and delete the matched text on a success."""
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
+
10
+ import pytest # noqa: E402
11
+
12
+ from memgres.store import build_replace # noqa: E402
13
+
14
+
15
+ def test_both_omitted_is_no_replace():
16
+ assert build_replace(None, None) is None
17
+
18
+
19
+ def test_both_present_builds_tuple():
20
+ assert build_replace("old", "new") == ("old", "new")
21
+
22
+
23
+ def test_explicit_empty_new_is_allowed_delete():
24
+ # deleting the matched text on purpose stays possible — but ONLY explicitly
25
+ assert build_replace("old", "") == ("old", "")
26
+
27
+
28
+ def test_lone_old_raises_not_silent_delete():
29
+ # the bug: this used to become ("old", "") and silently delete `old`
30
+ with pytest.raises(ValueError):
31
+ build_replace("old", None)
32
+
33
+
34
+ def test_lone_new_raises():
35
+ with pytest.raises(ValueError):
36
+ build_replace(None, "new")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(pytest.main([__file__, "-v"]))
@@ -109,6 +109,25 @@ def test_replace_edit_over_http(client):
109
109
  assert ok.status_code == 200 and ok.json()["body"] == "z z\n"
110
110
 
111
111
 
112
+ def test_replace_missing_new_errors_not_silent_delete(client):
113
+ # Regression (meta.memgres.replace_new_dropped): a lone replace_old with
114
+ # replace_new omitted must be REJECTED, not coerced to ("old", "") and
115
+ # silently delete the matched text on a 200.
116
+ r = client.post("/memories", json={"body": "keep\nanchor\ntail\n"})
117
+ mid, h0 = r.json()["id"], r.json()["content_hash"]
118
+ r = client.patch(f"/memories/{mid}", json={"replace_old": "anchor"})
119
+ assert r.status_code == 422
120
+ got = client.get(f"/memories/{mid}").json() # body untouched, no seq bump
121
+ assert got["body"] == "keep\nanchor\ntail\n" and got["content_hash"] == h0
122
+ # a lone replace_new is likewise rejected
123
+ assert client.patch(f"/memories/{mid}",
124
+ json={"replace_new": "x"}).status_code == 422
125
+ # an EXPLICIT empty replace_new is a deliberate deletion (still allowed)
126
+ r = client.patch(f"/memories/{mid}",
127
+ json={"replace_old": "anchor\n", "replace_new": ""})
128
+ assert r.status_code == 200 and r.json()["body"] == "keep\ntail\n"
129
+
130
+
112
131
  def test_recall_tag_and_subtree_filters(client):
113
132
  client.post("/memories", json={"body": "apple pie recipe\n", "tags": ["food"],
114
133
  "path": "recipes.apple"})
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