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.
- {memgres-0.4.0 → memgres-0.4.1}/PKG-INFO +1 -1
- {memgres-0.4.0 → memgres-0.4.1}/memgres/_version.py +1 -1
- {memgres-0.4.0 → memgres-0.4.1}/memgres/mcp_server.py +2 -4
- {memgres-0.4.0 → memgres-0.4.1}/memgres/server.py +5 -5
- {memgres-0.4.0 → memgres-0.4.1}/memgres/store.py +20 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/SOURCES.txt +1 -0
- memgres-0.4.1/tests/test_replace_build.py +40 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_server_integration.py +19 -0
- {memgres-0.4.0 → memgres-0.4.1}/LICENSE +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/README.md +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/__init__.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/blame.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/config.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/diffing.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/embed_worker.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/embeddings.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/identity.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/indexing.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/info.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/reembed.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/schema.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/search.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/segments.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/__init__.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/base.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/pgvector.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/vector/qdrant.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres/worker.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/pyproject.toml +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/setup.cfg +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_blame_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_chunk_index.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_config.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_diffing.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_embed_worker.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_embeddings.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_identity_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_lexical_match.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_limits.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_list.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_search_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_security_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_segments.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_segments_store.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_server_info.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_snippets.py +0 -0
- {memgres-0.4.0 → memgres-0.4.1}/tests/test_store_integration.py +0 -0
|
@@ -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 =
|
|
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
|
-
|
|
178
|
-
|
|
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=
|
|
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
|
|
@@ -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
|
|
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
|