Skip to content

Commit 41a68d5

Browse files
fix(cli): add source-scoped unmine command
1 parent 639c69a commit 41a68d5

3 files changed

Lines changed: 339 additions & 4 deletions

File tree

mempalace/cli.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
mempalace mine <dir> --mode convos Mine conversation exports
1818
mempalace mine <dir> --mode extract Mine binary office documents (PDF/DOCX/etc.)
1919
mempalace mine <source> --source NAME Mine through a registered source adapter
20+
mempalace unmine <source-file> Remove filed data for one exact source (dry-run first)
2021
mempalace search "query" Find anything, exact words
2122
mempalace mcp Show MCP setup command
2223
mempalace wake-up Show L0 + L1 wake-up context
@@ -1160,6 +1161,63 @@ def cmd_sync(args):
11601161
print(f"\n{'=' * 55}\n")
11611162

11621163

1164+
def cmd_unmine(args):
1165+
"""Remove all filed data associated with one exact source path."""
1166+
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
1167+
source_file = os.path.abspath(os.path.expanduser(args.source_file))
1168+
1169+
if not os.path.isdir(palace_path):
1170+
print(f"\n No palace found at {palace_path}")
1171+
return
1172+
1173+
from .palace import MineAlreadyRunning
1174+
from .sync import unmine_source
1175+
from .wal import _wal_log
1176+
1177+
print(f"\n{'=' * 55}")
1178+
print(" MemPalace Unmine -- Source-scoped removal")
1179+
print(f"{'=' * 55}")
1180+
print(f" Palace: {palace_path}")
1181+
print(f" Source: {source_file}")
1182+
print(f" Wing: {args.wing or 'all wings'}")
1183+
print(f" Mode: {'DRY RUN (no deletions)' if args.dry_run else 'APPLY'}")
1184+
print(f"{'-' * 55}\n")
1185+
1186+
try:
1187+
report = unmine_source(
1188+
palace_path=palace_path,
1189+
source_file=source_file,
1190+
wing=args.wing,
1191+
dry_run=args.dry_run,
1192+
wal_log=_wal_log,
1193+
)
1194+
except MineAlreadyRunning as exc:
1195+
print(f"mempalace: {exc}", file=sys.stderr)
1196+
sys.exit(1)
1197+
except ValueError as exc:
1198+
print(f"mempalace: {exc}", file=sys.stderr)
1199+
sys.exit(2)
1200+
except Exception as exc:
1201+
print(f"mempalace: unmine failed: {exc}", file=sys.stderr)
1202+
sys.exit(1)
1203+
1204+
wings = ", ".join(report["affected_wings"]) if report["affected_wings"] else "none"
1205+
print(f" Matched drawers: {report['matched_drawers']}")
1206+
print(f" Matched closets: {report['matched_closets']}")
1207+
print(f" Affected wings: {wings}")
1208+
1209+
if args.dry_run:
1210+
if report["matched_drawers"] or report["matched_closets"]:
1211+
print("\n Re-run with --apply to remove exactly this source.")
1212+
else:
1213+
print("\n Nothing filed from that source path.")
1214+
else:
1215+
print(
1216+
f"\n Removed {report['removed_drawers']} drawers, {report['removed_closets']} closets."
1217+
)
1218+
print(f"\n{'=' * 55}\n")
1219+
1220+
11631221
def _submit_daemon_cli_job(kind: str, payload: dict, args, *, background: bool) -> None:
11641222
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
11651223
backend = _backend_arg(args)
@@ -2676,6 +2734,27 @@ def main():
26762734
help="With --daemon, return a job id immediately instead of waiting",
26772735
)
26782736

2737+
# unmine
2738+
p_unmine = sub.add_parser(
2739+
"unmine",
2740+
help="Remove all filed data for one exact source path (dry-run by default)",
2741+
)
2742+
p_unmine.add_argument("source_file", help="Source file path stored on the filed drawers")
2743+
p_unmine.add_argument("--wing", default=None, help="Limit removal to one wing")
2744+
p_unmine.add_argument(
2745+
"--dry-run",
2746+
dest="dry_run",
2747+
action="store_true",
2748+
default=True,
2749+
help="Preview only (default)",
2750+
)
2751+
p_unmine.add_argument(
2752+
"--apply",
2753+
dest="dry_run",
2754+
action="store_false",
2755+
help="Actually remove matching drawers and closets",
2756+
)
2757+
26792758
# search
26802759
p_search = sub.add_parser("search", help="Find anything, exact words")
26812760
p_search.add_argument("query", help="What to search for")
@@ -3165,6 +3244,7 @@ def _add_logstream_filters(p):
31653244
"search": cmd_search,
31663245
"sweep": cmd_sweep,
31673246
"sync": cmd_sync,
3247+
"unmine": cmd_unmine,
31683248
"mcp": cmd_mcp,
31693249
"serve": cmd_serve,
31703250
"compress": cmd_compress,

mempalace/sync.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ class SyncReport(TypedDict):
4242
by_source: dict[str, int]
4343

4444

45+
class UnmineReport(TypedDict):
46+
source_file: str
47+
wing: Optional[str]
48+
matched_drawers: int
49+
matched_closets: int
50+
removed_drawers: int
51+
removed_closets: int
52+
affected_wings: list[str]
53+
dry_run: bool
54+
55+
4556
def _resolve_project_root(source_file: Path, project_roots: list) -> Optional[Path]:
4657
"""Return the longest project_root that source_file lives under.
4758
@@ -184,22 +195,134 @@ def _normalize_project_dirs(project_dirs) -> list:
184195
return sorted(resolved, key=lambda p: (-len(str(p)), str(p)))
185196

186197

187-
def _delete_in_batches(col, ids: list, batch_size: int, wal_log: Optional[Callable]):
188-
"""Delete drawer IDs in batches, optionally logging each batch to WAL."""
198+
def _delete_in_batches(
199+
col,
200+
ids: list,
201+
batch_size: int,
202+
wal_log: Optional[Callable],
203+
*,
204+
operation: str = "sync_prune",
205+
params: Optional[dict] = None,
206+
):
207+
"""Delete IDs in batches, optionally logging each batch to WAL."""
189208
deleted = 0
190209
for i in range(0, len(ids), batch_size):
191210
chunk = ids[i : i + batch_size]
192211
col.delete(ids=chunk)
193212
deleted += len(chunk)
194213
if wal_log is not None:
214+
wal_params = dict(params or {})
215+
wal_params["first_id"] = chunk[0]
195216
wal_log(
196-
"sync_prune",
197-
{"first_id": chunk[0]},
217+
operation,
218+
wal_params,
198219
{"removed_count": len(chunk)},
199220
)
200221
return deleted
201222

202223

224+
def _source_where(source_file: str, wing: Optional[str]):
225+
if wing:
226+
return {"$and": [{"source_file": source_file}, {"wing": wing}]}
227+
return {"source_file": source_file}
228+
229+
230+
def _matching_source_rows(col, source_file: str, wing: Optional[str]) -> tuple[list[str], set[str]]:
231+
"""Return IDs and observed wings for one exact source-file match."""
232+
ids: list[str] = []
233+
wings: set[str] = set()
234+
offset = 0
235+
where = _source_where(source_file, wing)
236+
while True:
237+
batch = col.get(
238+
where=where,
239+
limit=_BATCH,
240+
offset=offset,
241+
include=["metadatas"],
242+
)
243+
batch_ids = batch.get("ids") or []
244+
metadatas = batch.get("metadatas") or []
245+
if not batch_ids:
246+
break
247+
ids.extend(batch_ids)
248+
for meta in metadatas:
249+
value = (meta or {}).get("wing")
250+
if isinstance(value, str) and value:
251+
wings.add(value)
252+
offset += len(batch_ids)
253+
return ids, wings
254+
255+
256+
def unmine_source(
257+
palace_path: str,
258+
source_file: str,
259+
wing: Optional[str] = None,
260+
dry_run: bool = True,
261+
batch_size: int = _BATCH,
262+
wal_log: Optional[Callable] = None,
263+
) -> UnmineReport:
264+
"""Remove every drawer and closet filed from one exact source path.
265+
266+
Dry-run is the default. The operation is source-scoped rather than
267+
content-scoped on purpose: it gives operators a precise recovery path
268+
for accidental/duplicate mines without defining a global policy for when
269+
repeated text is semantically redundant. ``wing`` can further narrow a
270+
source that was intentionally filed into more than one wing.
271+
272+
Registry sentinels are ordinary drawer rows carrying ``source_file`` and
273+
are removed too, so a later mine of the source is not falsely skipped.
274+
"""
275+
if not source_file:
276+
raise ValueError("source_file must be a non-empty path")
277+
278+
with mine_palace_lock(palace_path):
279+
col = get_collection(palace_path, create=False)
280+
drawer_ids, affected_wings = _matching_source_rows(col, source_file, wing)
281+
282+
closets_col = None
283+
closet_ids: list[str] = []
284+
try:
285+
closets_col = get_closets_collection(palace_path, create=False)
286+
except Exception as exc:
287+
logger.debug("Unmine closet lookup skipped (collection unavailable): %s", exc)
288+
if closets_col is not None:
289+
closet_ids, closet_wings = _matching_source_rows(closets_col, source_file, wing)
290+
affected_wings.update(closet_wings)
291+
292+
report: UnmineReport = {
293+
"source_file": source_file,
294+
"wing": wing,
295+
"matched_drawers": len(drawer_ids),
296+
"matched_closets": len(closet_ids),
297+
"removed_drawers": 0,
298+
"removed_closets": 0,
299+
"affected_wings": sorted(affected_wings),
300+
"dry_run": dry_run,
301+
}
302+
if dry_run:
303+
return report
304+
305+
wal_params = {"source_file": source_file}
306+
if wing:
307+
wal_params["wing"] = wing
308+
report["removed_drawers"] = _delete_in_batches(
309+
col,
310+
drawer_ids,
311+
batch_size,
312+
wal_log,
313+
operation="unmine_source",
314+
params=wal_params,
315+
)
316+
if closets_col is not None:
317+
report["removed_closets"] = _delete_in_batches(
318+
closets_col,
319+
closet_ids,
320+
batch_size,
321+
None,
322+
)
323+
return report
324+
325+
203326
def sync_palace(
204327
palace_path: str,
205328
project_dirs: Optional[list] = None,
@@ -317,5 +440,7 @@ def sync_palace(
317440
__all__ = [
318441
"MineAlreadyRunning",
319442
"SyncReport",
443+
"UnmineReport",
320444
"sync_palace",
445+
"unmine_source",
321446
]

0 commit comments

Comments
 (0)