@@ -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