ReMe/reme4/steps/file_io/delete.py
jinliyl a4efc0f776
refactor(reme4): restructure steps packages (#258)
* fix(bm25_index): 修正BM25索引计算中的文档长度归一化问题

修复了在计算BM25相似度时对文档长度进行不正确归一化的bug,确保所有查询都能得到准确的相关性评分。

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* up

* refactor(steps): Rename and adjust indexing step logic

- Rename `scan_changes.py` and `reindex.py` to `clear_and_scan.py`
- Update implementation details of `ScanChangesStep` and `ClearAndScanStep`
- Modify the scheduling mechanism in `WatchChangesStep`
- Adjust step registration and parameter configuration in config files
- Update related tests to align with the new interface changes

* up

* feat(daily): replace daily CRUD operations with slug provisioning approach

* refactor(tests): migrate CRUD step tests from HTTP server to direct LocalFileStore

* up

* up

* up

* up

---------

Co-authored-by: huangsen <huangsen.huang@alibaba-inc.com>
2026-05-28 14:30:30 +08:00

132 lines
5.3 KiB
Python

"""``file_delete`` — hard-delete a file or folder under the vault (reports inbound refs).
Removes the file (or folder tree) from disk; the watcher then prunes
the affected chunks from all projections (vector / keyword / file_graph).
For **soft** deletion that keeps a file addressable, use
``property_update(status=archived)`` instead — that's the path
structure.md's Decay algorithm is designed around.
Reference reporting (no auto-fix). Before deleting, the inbound
wikilink count for each doomed ``.md`` file is captured from the
file_graph's reverse index (literal full-path matching, same rule
as the wikilink retarget helper). For folder deletes, sources that live inside
the same folder are filtered out — those links die alongside the
delete and can't surface as dangling. The remaining inbound list
is the agent's punch list for follow-up rewrites.
The delete itself is unconditional: this step does not rewrite
inbound references on the agent's behalf because there is no
canonical "new target" — the agent decides per-reference whether
to rewrite (via ``file_move`` to a merge target), edit the
citing prose, or accept dangling.
"""
import shutil
from pathlib import Path
from ..base_step import BaseStep
from ...components import R
from ...utils.wikilink_handler import WikilinkHandler
def _is_inside(rel: str, folder_rel: str) -> bool:
"""``rel`` is the same as or nested under ``folder_rel`` (relative to the vault)."""
prefix = folder_rel.rstrip("/") + "/"
return rel == folder_rel or rel.startswith(prefix)
@R.register("delete_step")
class DeleteStep(BaseStep):
"""Hard-delete the path at ``path`` (file or folder, relative to the vault)."""
async def execute(self):
assert self.context is not None
path: str = self.context.get("path", "") or ""
assert path, "path is required"
payload = await self._delete(path)
if "error" in payload:
self.context.response.success = False
self.context.response.answer = f"Error: {payload['error']}"
elif payload.get("is_dir"):
self.context.response.success = True
self.context.response.answer = f"Deleted directory {path} ({len(payload['deleted_files'])} file(s))"
else:
self.context.response.success = True
self.context.response.answer = f"Deleted {path}"
self.context.response.metadata.update(payload)
async def _delete(self, path: str) -> dict:
if not path:
return {"path": path, "error": "not found"}
vault_dir = Path(self.file_store.vault_path or ".").resolve()
target = (vault_dir / path).resolve()
if target.is_file():
inbound = await WikilinkHandler.find_inbound(self.file_store, target=path)
target.unlink()
return {
"path": path,
"deleted": True,
"is_dir": False,
"deleted_files": [path],
"inbound": {
"files_touched": inbound.get("files_touched", 0),
"links_total": inbound.get("links_total", 0),
"by_file": inbound.get("by_file", []),
},
}
if target.is_dir():
folder_rel = path.rstrip("/")
deleted_files: list[str] = []
per_target: list[dict] = []
external_sources: set[str] = set()
links_total = 0
for md in sorted(target.rglob("*.md")):
try:
rel = str(md.relative_to(vault_dir))
except ValueError:
continue
deleted_files.append(rel)
inbound = await WikilinkHandler.find_inbound(self.file_store, target=rel)
# Drop sources that also live inside the doomed folder —
# their links vanish with them and aren't actionable.
external = [row for row in inbound.get("by_file", []) if not _is_inside(row["path"], folder_rel)]
if not external:
continue
target_total = sum(row["count"] for row in external)
links_total += target_total
external_sources.update(row["path"] for row in external)
per_target.append(
{
"target": rel,
"files_touched": len(external),
"links_total": target_total,
"by_file": external,
},
)
# Also enumerate non-md files for the deleted_files report.
for entry in sorted(target.rglob("*")):
if not entry.is_file() or entry.suffix == ".md":
continue
try:
rel = str(entry.relative_to(vault_dir))
except ValueError:
continue
deleted_files.append(rel)
shutil.rmtree(target)
return {
"path": path,
"deleted": True,
"is_dir": True,
"deleted_files": sorted(deleted_files),
"inbound": {
"files_touched": len(external_sources),
"links_total": links_total,
"by_target": per_target,
},
}
return {"path": path, "error": "not found"}