Skip to content

Commit 9902e3a

Browse files
authored
Merge pull request #1729 from arnoldwender/feat/mcp-delete-by-source
feat(mcp): add mempalace_delete_by_source bulk-cleanup tool (#1722)
2 parents a91f7e0 + 5ae2315 commit 9902e3a

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

mempalace/mcp_server.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Tools (write):
1616
mempalace_add_drawer — file verbatim content into a wing/room
1717
mempalace_delete_drawer — remove a drawer by ID
18+
mempalace_delete_by_source — bulk-remove all drawers mined from one source_file
1819
1920
Tools (maintenance):
2021
mempalace_reconnect — force cache invalidation and reconnect after external writes
@@ -2505,6 +2506,158 @@ def _run():
25052506
_metadata_cache = None
25062507

25072508

2509+
def _purge_source_closets(source_file: str, *, commit: bool) -> int:
2510+
"""Count, and optionally delete, closets matching ``source_file`` exactly.
2511+
2512+
The closets collection is the searchable AAAK index layer; it is keyed by
2513+
``source_file`` independently of the drawers collection, so a drawer-only
2514+
delete would strand stale index pointers at the deleted source (#1722).
2515+
Mirrors the closet-purge step in :func:`mempalace.sync.sync_palace` and the
2516+
re-mine purge in :func:`mempalace.palace.purge_file_closets`.
2517+
2518+
Best-effort: a missing or unavailable closet collection yields 0 and never
2519+
raises, so it can never abort a drawer delete that has already committed.
2520+
Deletion is pushed down via ``delete(where=...)`` so it survives palaces
2521+
larger than the 10k ``get()`` truncation; the returned count is the (best
2522+
effort) number of matching closets observed before the delete.
2523+
"""
2524+
from .palace import get_closets_collection
2525+
2526+
try:
2527+
closets_col = get_closets_collection(_config.palace_path, create=False)
2528+
except Exception as exc:
2529+
logger.warning("Closet purge skipped (collection unavailable): %s", exc)
2530+
return 0
2531+
if closets_col is None:
2532+
return 0
2533+
try:
2534+
ids = closets_col.get(where={"source_file": source_file}, include=[]).get("ids") or []
2535+
count = len(ids)
2536+
if commit and count:
2537+
closets_col.delete(where={"source_file": source_file})
2538+
return count
2539+
except Exception as exc:
2540+
logger.warning("Closet purge failed for %s: %s", source_file, exc)
2541+
return 0
2542+
2543+
2544+
def tool_delete_by_source(source_file: str, dry_run: bool = True):
2545+
"""Delete every drawer whose ``source_file`` metadata matches exactly.
2546+
2547+
Bulk cleanup for the contamination case in #1722, where benchmark/eval
2548+
files (ShareGPT dumps, ``results_mempal_*.jsonl``, language config JSON)
2549+
get mined into the same wing as real user data and drown out semantic
2550+
search. Previously the only recourse was hand-rolled SQLite ``DELETE``
2551+
against ``chroma.sqlite3``.
2552+
2553+
Matching is exact on the stored ``source_file`` value and pushed down to
2554+
the backend via ``delete(where=...)`` — the same idiom used by the miner
2555+
and diary ingest paths — so there is no client-side id list and the
2556+
SQLite "too many variables" limit cannot be hit, regardless of how many
2557+
drawers share the source (the reporter had 55k).
2558+
2559+
Also purges the matching closets (the AAAK index layer) so deleting the
2560+
drawers doesn't strand stale index pointers at the dead source (#1722).
2561+
2562+
Defaults to a dry run: it reports the drawer match count, the closet match
2563+
count, and a small sample so the caller can confirm the blast radius before
2564+
anything is removed. Pass ``dry_run=False`` to commit the deletion
2565+
(irreversible).
2566+
"""
2567+
global _metadata_cache
2568+
if not isinstance(source_file, str) or not source_file.strip():
2569+
return {"success": False, "error": "source_file must be a non-empty string"}
2570+
# Mirror the ingestion-side normalization (tool_add_drawer strips lone
2571+
# surrogates from source_file before storing) so exact matching still hits
2572+
# rows mined from non-ASCII paths that arrived via a cp1252 stdin (#1488).
2573+
source_file = strip_lone_surrogates(source_file)
2574+
2575+
col = _get_collection()
2576+
if not col:
2577+
return _collection_error_or_no_palace()
2578+
2579+
where = {"source_file": source_file}
2580+
try:
2581+
# Paginated to survive palaces larger than the 10k get() truncation.
2582+
metas = _fetch_all_metadata(col, where=where)
2583+
except Exception as e:
2584+
return {"success": False, "error": str(e)}
2585+
2586+
match_count = len(metas)
2587+
# Distinct (wing, room) pairs so the caller sees where the hits live.
2588+
sample = []
2589+
seen = set()
2590+
for meta in metas:
2591+
meta = _safe_meta(meta)
2592+
# Default missing wing/room to "" for consistency with the rest of the
2593+
# file (drawers are always stored with both, but be defensive).
2594+
wing = meta.get("wing", "")
2595+
room = meta.get("room", "")
2596+
key = (wing, room)
2597+
if key in seen:
2598+
continue
2599+
seen.add(key)
2600+
sample.append({"wing": wing, "room": room})
2601+
if len(sample) >= 5:
2602+
break
2603+
2604+
if dry_run:
2605+
closet_match_count = _purge_source_closets(source_file, commit=False)
2606+
return {
2607+
"success": True,
2608+
"dry_run": True,
2609+
"source_file": source_file,
2610+
"match_count": match_count,
2611+
"closet_match_count": closet_match_count,
2612+
"sample": sample,
2613+
"hint": (
2614+
"No drawers were deleted. Re-run with dry_run=false to remove "
2615+
f"these {match_count} drawer(s) and {closet_match_count} index "
2616+
"entr(y/ies)."
2617+
if match_count
2618+
else "No drawers match this source_file."
2619+
),
2620+
}
2621+
2622+
if match_count == 0:
2623+
# Idempotent: deleting an absent source is a no-op, not an error.
2624+
return {
2625+
"success": True,
2626+
"dry_run": False,
2627+
"source_file": source_file,
2628+
"deleted": 0,
2629+
}
2630+
2631+
_wal_log(
2632+
"delete_by_source",
2633+
{"source_file": source_file, "match_count": match_count, "sample": sample},
2634+
)
2635+
try:
2636+
col.delete(where=where)
2637+
_metadata_cache = None
2638+
# Purge the matching closets too so the AAAK index doesn't keep stale
2639+
# pointers at the now-deleted drawers (#1722). Done after the drawer
2640+
# delete and intentionally best-effort: the drawers are already gone,
2641+
# so a closet-purge hiccup must not turn a successful delete into an
2642+
# error — it just leaves index cruft a later `repair` / re-mine clears.
2643+
closets_deleted = _purge_source_closets(source_file, commit=True)
2644+
logger.info(
2645+
"Deleted %d drawer(s) and %d closet(s) from source: %s",
2646+
match_count,
2647+
closets_deleted,
2648+
source_file,
2649+
)
2650+
return {
2651+
"success": True,
2652+
"dry_run": False,
2653+
"source_file": source_file,
2654+
"deleted": match_count,
2655+
"closets_deleted": closets_deleted,
2656+
}
2657+
except Exception as e:
2658+
return {"success": False, "error": str(e)}
2659+
2660+
25082661
def tool_sync(project_dir: str = None, wing: str = None, apply: bool = False):
25092662
"""Prune drawers whose source files are gitignored, missing, or moved (#1252)."""
25102663
global _metadata_cache
@@ -3764,6 +3917,24 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
37643917
},
37653918
"handler": tool_mine,
37663919
},
3920+
"mempalace_delete_by_source": {
3921+
"description": "Bulk-delete every drawer mined from one source_file (exact match). Use to clean up benchmark/test data accidentally mined into a user wing (#1722). Returns a dry-run match count and sample by default; pass dry_run=false to commit. Irreversible.",
3922+
"input_schema": {
3923+
"type": "object",
3924+
"properties": {
3925+
"source_file": {
3926+
"type": "string",
3927+
"description": "Exact source_file metadata value to remove (e.g. the full path that was mined)",
3928+
},
3929+
"dry_run": {
3930+
"type": "boolean",
3931+
"description": "Preview the match count without deleting; default true. Pass false to actually delete.",
3932+
},
3933+
},
3934+
"required": ["source_file"],
3935+
},
3936+
"handler": tool_delete_by_source,
3937+
},
37673938
"mempalace_sync": {
37683939
"description": "Prune drawers whose source files are gitignored, deleted, or moved. Returns dry-run report by default; pass apply=true to commit deletions.",
37693940
"input_schema": {

tests/test_mcp_server.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,6 +2235,194 @@ def test_update_drawer_chunked_logical_id_rewrites_group(monkeypatch, config, pa
22352235
assert listed["drawers"][0]["drawer_id"] == logical_id
22362236

22372237

2238+
# ── Delete by source (#1722) ────────────────────────────────────────────
2239+
2240+
2241+
class TestDeleteBySource:
2242+
"""``tool_delete_by_source`` — bulk cleanup of benchmark/test contamination (#1722)."""
2243+
2244+
def _seed(self, monkeypatch, config, palace_path, kg):
2245+
_patch_mcp_server(monkeypatch, config, kg)
2246+
_client, _col = _get_collection(palace_path, create=True)
2247+
del _client
2248+
from mempalace.mcp_server import tool_add_drawer
2249+
2250+
# Two drawers from a "benchmark" source, one from real user data.
2251+
tool_add_drawer(
2252+
wing="bench",
2253+
room="general",
2254+
content="ShareGPT yoga retreat conversation noise number one.",
2255+
source_file="results_mempal_hybrid_v4_session_1.jsonl",
2256+
)
2257+
tool_add_drawer(
2258+
wing="bench",
2259+
room="general",
2260+
content="ShareGPT coding job description noise number two.",
2261+
source_file="results_mempal_hybrid_v4_session_1.jsonl",
2262+
)
2263+
tool_add_drawer(
2264+
wing="clients",
2265+
room="webdesign",
2266+
content="GG Sauna Dachdecker real client memory that must survive.",
2267+
source_file="notes/clients.md",
2268+
)
2269+
2270+
def _seed_closets(self, palace_path):
2271+
"""Seed the AAAK index (closets) directly.
2272+
2273+
``tool_add_drawer`` never builds closets — those are a miner-side
2274+
artifact — so to exercise the closet purge we add them straight to the
2275+
collection, keyed by the same ``source_file`` the drawers use: two for
2276+
the benchmark source, one for the real-client source.
2277+
"""
2278+
from mempalace.palace import get_closets_collection
2279+
2280+
closets_col = get_closets_collection(palace_path, create=True)
2281+
closets_col.add(
2282+
ids=["bench_closet_01", "bench_closet_02", "client_closet_01"],
2283+
documents=[
2284+
"topic: yoga retreat | coding job",
2285+
"topic: more bench noise",
2286+
"topic: GG Sauna client",
2287+
],
2288+
metadatas=[
2289+
{"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
2290+
{"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
2291+
{"source_file": "notes/clients.md"},
2292+
],
2293+
)
2294+
return closets_col
2295+
2296+
def test_dry_run_reports_count_without_deleting(self, monkeypatch, config, palace_path, kg):
2297+
self._seed(monkeypatch, config, palace_path, kg)
2298+
from mempalace.mcp_server import tool_delete_by_source, tool_status
2299+
2300+
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl")
2301+
assert result["success"] is True
2302+
assert result["dry_run"] is True
2303+
assert result["match_count"] == 2
2304+
assert {"wing": "bench", "room": "general"} in result["sample"]
2305+
# Nothing removed — all three drawers still present.
2306+
assert tool_status()["total_drawers"] == 3
2307+
2308+
def test_dry_run_reports_closet_match_count(self, monkeypatch, config, palace_path, kg):
2309+
"""Dry run surfaces the closet blast radius (#1722) without deleting."""
2310+
self._seed(monkeypatch, config, palace_path, kg)
2311+
closets_col = self._seed_closets(palace_path)
2312+
from mempalace.mcp_server import tool_delete_by_source
2313+
2314+
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl")
2315+
assert result["dry_run"] is True
2316+
assert result["closet_match_count"] == 2
2317+
# Nothing removed — all three closets still present.
2318+
assert len(closets_col.get(include=[])["ids"]) == 3
2319+
2320+
def test_commit_deletes_only_matching_source(self, monkeypatch, config, palace_path, kg):
2321+
self._seed(monkeypatch, config, palace_path, kg)
2322+
from mempalace.mcp_server import tool_delete_by_source, tool_status
2323+
2324+
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl", dry_run=False)
2325+
assert result["success"] is True
2326+
assert result["dry_run"] is False
2327+
assert result["deleted"] == 2
2328+
# Only the real client drawer remains.
2329+
assert tool_status()["total_drawers"] == 1
2330+
2331+
def test_commit_purges_matching_closets(self, monkeypatch, config, palace_path, kg):
2332+
"""Deleting by source purges the matching closets too, so the AAAK
2333+
index keeps no stale pointers at the now-deleted drawers (#1722)."""
2334+
self._seed(monkeypatch, config, palace_path, kg)
2335+
closets_col = self._seed_closets(palace_path)
2336+
from mempalace.mcp_server import tool_delete_by_source
2337+
2338+
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl", dry_run=False)
2339+
assert result["success"] is True
2340+
assert result["deleted"] == 2
2341+
assert result["closets_deleted"] == 2
2342+
# The two benchmark closets are gone; the real-client closet survives.
2343+
remaining = closets_col.get(include=["metadatas"])
2344+
sources = {m["source_file"] for m in remaining["metadatas"]}
2345+
assert sources == {"notes/clients.md"}
2346+
2347+
def test_no_match_is_idempotent_not_error(self, monkeypatch, config, palace_path, kg):
2348+
self._seed(monkeypatch, config, palace_path, kg)
2349+
from mempalace.mcp_server import tool_delete_by_source, tool_status
2350+
2351+
result = tool_delete_by_source("does/not/exist.jsonl", dry_run=False)
2352+
assert result["success"] is True
2353+
assert result["deleted"] == 0
2354+
assert tool_status()["total_drawers"] == 3
2355+
2356+
def test_empty_source_file_rejected(self, monkeypatch, config, palace_path, kg):
2357+
self._seed(monkeypatch, config, palace_path, kg)
2358+
from mempalace.mcp_server import tool_delete_by_source
2359+
2360+
result = tool_delete_by_source(" ", dry_run=False)
2361+
assert result["success"] is False
2362+
assert "non-empty" in result["error"]
2363+
2364+
def test_non_string_source_rejected(self, monkeypatch, config, palace_path, kg):
2365+
"""A non-string source_file must return a clean error, not AttributeError."""
2366+
self._seed(monkeypatch, config, palace_path, kg)
2367+
from mempalace.mcp_server import tool_delete_by_source
2368+
2369+
result = tool_delete_by_source(123, dry_run=False)
2370+
assert result["success"] is False
2371+
assert "non-empty" in result["error"]
2372+
2373+
def test_matches_after_surrogate_normalization(self, monkeypatch, config, palace_path, kg):
2374+
"""source_file is stripped of lone surrogates on both ingest and delete,
2375+
so a path that arrived via a cp1252 stdin (#1488) still matches."""
2376+
_patch_mcp_server(monkeypatch, config, kg)
2377+
_client, _col = _get_collection(palace_path, create=True)
2378+
del _client
2379+
from mempalace.mcp_server import (
2380+
tool_add_drawer,
2381+
tool_delete_by_source,
2382+
tool_status,
2383+
)
2384+
2385+
# Lone low surrogate embedded in the path — add_drawer strips it.
2386+
raw_source = "noise\udce9_data.jsonl"
2387+
tool_add_drawer(
2388+
wing="bench",
2389+
room="general",
2390+
content="benchmark noise from a non-ASCII path",
2391+
source_file=raw_source,
2392+
)
2393+
assert tool_status()["total_drawers"] == 1
2394+
2395+
# Deleting with the same raw (un-stripped) string must still match.
2396+
result = tool_delete_by_source(raw_source, dry_run=False)
2397+
assert result["success"] is True
2398+
assert result["deleted"] == 1
2399+
assert tool_status()["total_drawers"] == 0
2400+
2401+
def test_registered_and_dispatchable(self, monkeypatch, config, palace_path, kg):
2402+
self._seed(monkeypatch, config, palace_path, kg)
2403+
from mempalace.mcp_server import handle_request
2404+
2405+
# Listed in tools/list
2406+
listed = handle_request({"method": "tools/list", "id": 1, "params": {}})
2407+
names = {t["name"] for t in listed["result"]["tools"]}
2408+
assert "mempalace_delete_by_source" in names
2409+
2410+
# Dispatches and defaults to dry-run (no destructive side effect)
2411+
resp = handle_request(
2412+
{
2413+
"method": "tools/call",
2414+
"id": 2,
2415+
"params": {
2416+
"name": "mempalace_delete_by_source",
2417+
"arguments": {"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
2418+
},
2419+
}
2420+
)
2421+
content = json.loads(resp["result"]["content"][0]["text"])
2422+
assert content["dry_run"] is True
2423+
assert content["match_count"] == 2
2424+
2425+
22382426
# ── KG Tools ────────────────────────────────────────────────────────────
22392427

22402428

0 commit comments

Comments
 (0)