mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-21 00:22:45 +00:00
refactor(parser): rename wikilink_resolver to link_parser module
BREAKING CHANGE: renamed `utils.wikilink_resolver` to `utils.link_parser`
This commit is contained in:
parent
1852d05f7b
commit
2e8a27ec5f
18 changed files with 825 additions and 619 deletions
|
|
@ -29,7 +29,7 @@ from ...schema import (
|
|||
FileNode,
|
||||
)
|
||||
from ...utils import hash_text
|
||||
from ...utils.wikilink_resolver import text_to_links
|
||||
from ...utils.link_parser import text_to_links
|
||||
|
||||
# -- AST node + helpers ---------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ One type, two states (the value of ``path`` distinguishes them):
|
|||
* **resolved** — ``path`` holds the vault-relative path
|
||||
file_graph stores, e.g. ``"topics/Foo/Foo.md"``.
|
||||
|
||||
The extractor (``utils.wikilink_resolver.iter_links``) produces the
|
||||
pre-resolution form from body text. The resolver
|
||||
(``utils.wikilink_resolver.resolve_links``, or one-shot
|
||||
``text_to_links``) rewrites ``path`` to the resolved form, expanding
|
||||
stem ambiguity into one ``FileLink`` per candidate.
|
||||
The extractor (``utils.link_parser.iter_links``) produces the
|
||||
pre-resolution form from body text. The batch resolver
|
||||
(``utils.link_parser.resolve_links``, or one-shot ``text_to_links``)
|
||||
delegates to ``utils.path_resolver.resolve`` to rewrite ``path`` to
|
||||
the resolved form, expanding short-path ambiguity into one
|
||||
``FileLink`` per candidate.
|
||||
|
||||
file_graph trusts ``link.path`` directly for adjacency: it only ever
|
||||
stores resolved links. The pre-resolution form is internal pipeline
|
||||
|
|
@ -47,8 +48,8 @@ class FileLink(BaseModel):
|
|||
description=(
|
||||
"Wikilink target. Pre-resolution: the raw target as written "
|
||||
"(e.g. 'Foo'). Resolved: the vault-relative path file_graph "
|
||||
"stores. Stem ambiguity is resolved BEFORE construction of "
|
||||
"the resolved form by emitting one FileLink per candidate."
|
||||
"stores. Short-path ambiguity is resolved BEFORE construction "
|
||||
"of the resolved form by emitting one FileLink per candidate."
|
||||
),
|
||||
)
|
||||
anchor: str | None = Field(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ The temp root is lazy and session-scoped — created on first download,
|
|||
left for the OS to clean up at process exit. Each download lands in
|
||||
its own subdirectory so concurrent agents don't trample each other.
|
||||
|
||||
Also exports ``resolve_path`` — the shared helper for turning
|
||||
a vault-relative or absolute path into an absolute on-disk path.
|
||||
``upload`` and ``list`` import it from here.
|
||||
``path`` accepts a short link or a vault-relative path; resolution
|
||||
goes through ``path_resolver.resolve_to_absolute``. Short-link
|
||||
ambiguity is surfaced as ``error="ambiguous"`` with the candidate
|
||||
list — the download is **not** executed in that case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -26,6 +27,7 @@ from ..runtime_response import _set_answer, _tool_response
|
|||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...utils import path_resolver
|
||||
|
||||
|
||||
_TEMP_ROOT: Path | None = None
|
||||
|
|
@ -39,20 +41,6 @@ def _get_temp_root() -> Path:
|
|||
return _TEMP_ROOT
|
||||
|
||||
|
||||
def resolve_path(file_store, path: str) -> Path:
|
||||
"""Compose the absolute on-disk path for relative entry.
|
||||
|
||||
Public so sibling steps (``upload``, ``list``, event tools) can
|
||||
reuse the same path-resolution rule. Absolute paths pass through;
|
||||
relative paths join under ``file_store.working_dir``.
|
||||
"""
|
||||
working_dir = getattr(file_store, "working_dir", None) or "."
|
||||
p = Path(path)
|
||||
if p.is_absolute():
|
||||
return p.resolve()
|
||||
return (Path(working_dir) / p).resolve()
|
||||
|
||||
|
||||
@R.register("file_download")
|
||||
class FileDownload(BaseStep):
|
||||
"""Copy a vault file to a session temp dir; return the local path."""
|
||||
|
|
@ -65,18 +53,23 @@ class FileDownload(BaseStep):
|
|||
assert self.context is not None
|
||||
path: str = self.context.get("path", "") or ""
|
||||
assert path, "path is required"
|
||||
payload = self._download(path)
|
||||
payload = await self._download(path)
|
||||
self.context.response.success = "error" not in payload
|
||||
_set_answer(self.context, payload)
|
||||
|
||||
async def file_download(self, path: str) -> ToolResponse:
|
||||
"""Copy a vault file to session temp dir; return the local path."""
|
||||
payload = self._download(path)
|
||||
payload = await self._download(path)
|
||||
ok = "error" not in payload
|
||||
return _tool_response("file_download", ok, payload, audit=self.audit)
|
||||
|
||||
def _download(self, path: str) -> dict:
|
||||
src = resolve_path(self.file_store, path)
|
||||
async def _download(self, path: str) -> dict:
|
||||
try:
|
||||
src = await path_resolver.resolve_to_absolute(self.file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return {"path": path, "error": "ambiguous", "candidates": e.candidates}
|
||||
except path_resolver.PathNotFound:
|
||||
return {"path": path, "error": "not found"}
|
||||
if not src.is_file():
|
||||
return {"path": path, "error": "not found"}
|
||||
dst_dir = Path(tempfile.mkdtemp(prefix="dl-", dir=_get_temp_root()))
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
"""``file_list`` — enumerate vault files with optional frontmatter filters.
|
||||
"""``file_list`` — enumerate vault files under a directory.
|
||||
|
||||
Walks the file_graph in one shot via ``get_nodes(None)`` — no filesystem
|
||||
scan, no per-file frontmatter parse, no per-file graph round-trip.
|
||||
Walks the file_graph in one shot via ``get_nodes(None)`` — no
|
||||
filesystem scan, no per-file frontmatter parse, no per-file graph
|
||||
round-trip.
|
||||
|
||||
Filters compose:
|
||||
path_prefix — prefix match against the indexed path string
|
||||
tags — every requested tag must be present in ``tags``
|
||||
metadata — frontmatter must equal each ``{key: value}`` pair
|
||||
|
||||
The ``metadata`` filter sees the full frontmatter dict — typed fields
|
||||
(title / description / tags) merged with any ``extra=allow`` extras —
|
||||
so agents can filter on schema-known keys or arbitrary extras alike.
|
||||
Parameters:
|
||||
path — directory to list under (vault-relative or absolute).
|
||||
Empty = working_dir root. Short links are not
|
||||
meaningful for directories.
|
||||
limit — cap the number of returned items.
|
||||
recursive — descend into subdirectories. Default False = direct
|
||||
children only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..base_step import BaseStep
|
||||
|
|
@ -22,43 +24,31 @@ from ..runtime_response import _set_answer, _tool_response
|
|||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...utils import path_resolver
|
||||
|
||||
|
||||
def _matches(
|
||||
path: str,
|
||||
md: dict,
|
||||
*,
|
||||
path_prefix: str | None,
|
||||
tags: list[str],
|
||||
metadata: dict,
|
||||
) -> bool:
|
||||
if path_prefix and not path.startswith(path_prefix):
|
||||
return False
|
||||
if metadata and any(md.get(k) != v for k, v in metadata.items()):
|
||||
return False
|
||||
if tags:
|
||||
file_tags = set(md.get("tags") or [])
|
||||
if not all(t in file_tags for t in tags):
|
||||
return False
|
||||
return True
|
||||
def _under(node_abs: Path, target_dir: Path, *, recursive: bool) -> bool:
|
||||
if recursive:
|
||||
return node_abs == target_dir or target_dir in node_abs.parents
|
||||
return node_abs.parent == target_dir
|
||||
|
||||
|
||||
async def _list(
|
||||
file_store,
|
||||
*,
|
||||
path_prefix: str | None,
|
||||
tags: list[str],
|
||||
metadata: dict,
|
||||
path: str,
|
||||
recursive: bool,
|
||||
limit: int,
|
||||
) -> dict:
|
||||
if not file_store.file_graph:
|
||||
return {"items": [], "count": 0}
|
||||
target_dir = path_resolver.to_absolute(file_store, path or ".")
|
||||
items: list[dict] = []
|
||||
for node in await file_store.file_graph.get_nodes():
|
||||
md = node.front_matter.model_dump()
|
||||
if not _matches(node.path, md, path_prefix=path_prefix, tags=tags, metadata=metadata):
|
||||
node_abs = path_resolver.to_absolute(file_store, node.path)
|
||||
if not _under(node_abs, target_dir, recursive=recursive):
|
||||
continue
|
||||
items.append({"path": node.path, "metadata": md})
|
||||
items.append({"path": node.path, "metadata": node.front_matter.model_dump()})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
return {"items": items, "count": len(items)}
|
||||
|
|
@ -66,7 +56,7 @@ async def _list(
|
|||
|
||||
@R.register("file_list")
|
||||
class FileList(BaseStep):
|
||||
"""Enumerate vault files with optional frontmatter filters."""
|
||||
"""Enumerate vault files under a directory."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
|
|
@ -76,26 +66,31 @@ class FileList(BaseStep):
|
|||
assert self.context is not None
|
||||
result = await _list(
|
||||
self.file_store,
|
||||
path_prefix=self.context.get("prefix") or self.context.get("path_prefix"),
|
||||
tags=self.context.get("tags") or [],
|
||||
metadata=self.context.get("metadata") or {},
|
||||
path=self.context.get("path") or "",
|
||||
recursive=bool(self.context.get("recursive", False)),
|
||||
limit=int(self.context.get("limit") or 100),
|
||||
)
|
||||
_set_answer(self.context, result)
|
||||
|
||||
async def file_list(
|
||||
self,
|
||||
prefix: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict | None = None,
|
||||
path: str = "",
|
||||
limit: int = 100,
|
||||
recursive: bool = False,
|
||||
) -> ToolResponse:
|
||||
"""List vault files. Filters: path prefix, frontmatter tags / fields."""
|
||||
"""List vault files under ``path``.
|
||||
|
||||
Args:
|
||||
path: directory to list (relative to working_dir or absolute).
|
||||
Empty = working_dir root.
|
||||
limit: cap the number of returned items. Default 100.
|
||||
recursive: descend into subdirectories. Default False =
|
||||
direct children only.
|
||||
"""
|
||||
result = await _list(
|
||||
self.file_store,
|
||||
path_prefix=prefix,
|
||||
tags=tags or [],
|
||||
metadata=metadata or {},
|
||||
path=path,
|
||||
recursive=recursive,
|
||||
limit=limit,
|
||||
)
|
||||
return _tool_response("file_list", True, result, audit=self.audit)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ Returns both ``deleted`` (keys that were present and removed) and
|
|||
a no-op happened. The file is rewritten only when at least one key
|
||||
is actually removed — calling delete with all-missing keys is a
|
||||
zero-side-effect read.
|
||||
|
||||
``path`` accepts a short link or vault-relative path. Short-link
|
||||
ambiguity is reported via ``error="ambiguous"`` with the candidate
|
||||
list — the delete is **not** executed in that case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,17 +16,21 @@ from __future__ import annotations
|
|||
import frontmatter
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..download import resolve_path
|
||||
|
||||
from ...base_step import BaseStep
|
||||
from ...runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ....component import R
|
||||
from ....enumeration import ComponentEnum
|
||||
from ....utils import path_resolver
|
||||
|
||||
|
||||
def _delete(file_store, path: str, keys: list[str]) -> dict:
|
||||
target = resolve_path(file_store, path)
|
||||
async def _delete(file_store, path: str, keys: list[str]) -> dict:
|
||||
try:
|
||||
target = await path_resolver.resolve_to_absolute(file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return {"path": path, "error": "ambiguous", "candidates": e.candidates}
|
||||
except path_resolver.PathNotFound:
|
||||
return {"path": path, "error": "not found"}
|
||||
if not target.is_file():
|
||||
return {"path": path, "error": "not found"}
|
||||
if not keys:
|
||||
|
|
@ -57,12 +65,12 @@ class PropertyDelete(BaseStep):
|
|||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
path: str = self.context.get("path") or self.context.get("path") or ""
|
||||
path: str = self.context.get("path") or ""
|
||||
assert path, "path is required"
|
||||
keys = self.context.get("keys") or []
|
||||
if isinstance(keys, str):
|
||||
keys = [keys]
|
||||
payload = _delete(self.file_store, path, list(keys))
|
||||
payload = await _delete(self.file_store, path, list(keys))
|
||||
self.context.response.success = "error" not in payload
|
||||
_set_answer(self.context, payload)
|
||||
|
||||
|
|
@ -74,6 +82,6 @@ class PropertyDelete(BaseStep):
|
|||
"""Remove the listed ``keys`` from the frontmatter at ``path``."""
|
||||
if isinstance(keys, str):
|
||||
keys = [keys]
|
||||
payload = _delete(self.file_store, path, list(keys))
|
||||
payload = await _delete(self.file_store, path, list(keys))
|
||||
ok = "error" not in payload
|
||||
return _tool_response("property:delete", ok, payload, audit=self.audit)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
Cheap structured read — frontmatter only, no body. Use ``crud:read``
|
||||
when you need the body too. Returns ``{exists: false}`` when the
|
||||
target doesn't exist; otherwise ``{exists: true, frontmatter: {...}}``.
|
||||
|
||||
``path`` accepts a short link or vault-relative path. Short-link
|
||||
ambiguity is reported via ``error="ambiguous"`` with the candidate
|
||||
list — the read is **not** executed in that case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,17 +14,21 @@ from __future__ import annotations
|
|||
import frontmatter
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..download import resolve_path
|
||||
|
||||
from ...base_step import BaseStep
|
||||
from ...runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ....component import R
|
||||
from ....enumeration import ComponentEnum
|
||||
from ....utils import path_resolver
|
||||
|
||||
|
||||
def _read(file_store, path: str) -> dict:
|
||||
target = resolve_path(file_store, path)
|
||||
async def _read(file_store, path: str) -> dict:
|
||||
try:
|
||||
target = await path_resolver.resolve_to_absolute(file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return {"path": path, "error": "ambiguous", "candidates": e.candidates}
|
||||
except path_resolver.PathNotFound:
|
||||
return {"path": path, "exists": False}
|
||||
if not target.is_file():
|
||||
return {"path": path, "exists": False}
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
|
|
@ -38,14 +46,14 @@ class PropertyRead(BaseStep):
|
|||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
path: str = self.context.get("path") or self.context.get("path") or ""
|
||||
path: str = self.context.get("path") or ""
|
||||
assert path, "path is required"
|
||||
payload = _read(self.file_store, path)
|
||||
payload = await _read(self.file_store, path)
|
||||
self.context.response.success = payload.get("exists", False)
|
||||
_set_answer(self.context, payload)
|
||||
|
||||
async def property_read(self, path: str) -> ToolResponse:
|
||||
"""Return the frontmatter dict of the memory file at ``path``."""
|
||||
payload = _read(self.file_store, path)
|
||||
payload = await _read(self.file_store, path)
|
||||
ok = payload.get("exists", False)
|
||||
return _tool_response("property:read", ok, payload, audit=self.audit)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
"""``property:update`` — merge a patch into a memory file's frontmatter.
|
||||
|
||||
Patch semantics are merge-write: keys in ``patch`` overwrite existing
|
||||
keys, keys not in ``patch`` are left untouched. To remove a key,
|
||||
use ``property:delete`` — passing ``None`` here just sets the literal
|
||||
None (which is rarely what you want).
|
||||
Read-modify-write the YAML frontmatter; body content is untouched.
|
||||
The watcher / parser pick up the change asynchronously.
|
||||
|
||||
Per the obsidian config convention the agent calls this with
|
||||
arbitrary keyword arguments — ``path="My Note" status=done xx=xxx``.
|
||||
The Step packs everything except ``path`` into the patch dict, so
|
||||
the tool method accepts ``**fields`` directly.
|
||||
Two input modes (mutually compatible — they merge in order):
|
||||
* ``patch`` — explicit ``dict`` of frontmatter updates.
|
||||
* ``**fields`` — free-form key/value pairs (obsidian convention
|
||||
``path=... key=val key=val``); merged on top of ``patch``.
|
||||
|
||||
``path`` accepts a short link or vault-relative path. Short-link
|
||||
ambiguity is reported via ``error="ambiguous"`` with the candidate
|
||||
list — the update is **not** executed in that case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,25 +18,27 @@ from __future__ import annotations
|
|||
import frontmatter
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..download import resolve_path
|
||||
|
||||
from ...base_step import BaseStep
|
||||
from ...runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ....component import R
|
||||
from ....enumeration import ComponentEnum
|
||||
from ....utils import path_resolver
|
||||
|
||||
|
||||
# Context keys that belong to plumbing (request envelope, response
|
||||
# slot, the path itself) and must never be promoted into a free-form
|
||||
# frontmatter patch.
|
||||
_RESERVED_CONTEXT_KEYS = {
|
||||
"path", "path", "patch", "response", "request", "data",
|
||||
}
|
||||
_RESERVED_CONTEXT_KEYS = {"path", "patch", "response", "request", "data"}
|
||||
|
||||
|
||||
def _update(file_store, path: str, patch: dict) -> dict:
|
||||
target = resolve_path(file_store, path)
|
||||
async def _update(file_store, path: str, patch: dict) -> dict:
|
||||
try:
|
||||
target = await path_resolver.resolve_to_absolute(file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return {"path": path, "error": "ambiguous", "candidates": e.candidates}
|
||||
except path_resolver.PathNotFound:
|
||||
return {"path": path, "error": "not found"}
|
||||
if not target.is_file():
|
||||
return {"path": path, "error": "not found"}
|
||||
if not patch:
|
||||
|
|
@ -43,11 +47,7 @@ def _update(file_store, path: str, patch: dict) -> dict:
|
|||
post = frontmatter.loads(raw)
|
||||
post.metadata.update(patch)
|
||||
target.write_text(frontmatter.dumps(post), encoding="utf-8")
|
||||
return {
|
||||
"path": path,
|
||||
"applied": dict(patch),
|
||||
"frontmatter": dict(post.metadata),
|
||||
}
|
||||
return {"path": path, "updated": list(patch.keys())}
|
||||
|
||||
|
||||
@R.register("property:update")
|
||||
|
|
@ -60,7 +60,7 @@ class PropertyUpdate(BaseStep):
|
|||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
path: str = self.context.get("path") or self.context.get("path") or ""
|
||||
path: str = self.context.get("path") or ""
|
||||
assert path, "path is required"
|
||||
patch = self.context.get("patch")
|
||||
if patch is None:
|
||||
|
|
@ -71,7 +71,7 @@ class PropertyUpdate(BaseStep):
|
|||
k: v for k, v in self.context.data.items()
|
||||
if k not in _RESERVED_CONTEXT_KEYS
|
||||
}
|
||||
payload = _update(self.file_store, path, dict(patch or {}))
|
||||
payload = await _update(self.file_store, path, dict(patch or {}))
|
||||
self.context.response.success = "error" not in payload
|
||||
_set_answer(self.context, payload)
|
||||
|
||||
|
|
@ -88,6 +88,6 @@ class PropertyUpdate(BaseStep):
|
|||
merged.update(patch)
|
||||
if fields:
|
||||
merged.update(fields)
|
||||
payload = _update(self.file_store, path, merged)
|
||||
payload = await _update(self.file_store, path, merged)
|
||||
ok = "error" not in payload
|
||||
return _tool_response("property:update", ok, payload, audit=self.audit)
|
||||
|
|
|
|||
|
|
@ -13,24 +13,28 @@ Returns a uniform envelope:
|
|||
is ``"file"`` / ``"dir"`` (covers event workspace probes too).
|
||||
``frontmatter`` is populated only for ``.md`` files and only when
|
||||
parsing succeeds — schema validity is a lint concern.
|
||||
|
||||
``path`` accepts a short link, vault-relative path, or directory
|
||||
path. File-style inputs go through ``path_resolver.resolve_to_absolute``
|
||||
so short-link ambiguity surfaces as ``error="ambiguous"`` (with
|
||||
candidates) and the call is **not** executed. Directory paths and
|
||||
non-indexed files fall back to a plain ``working_dir`` join.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from .download import resolve_path
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...utils import path_resolver
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
|
|
@ -38,15 +42,22 @@ def _iso(ts: float) -> str:
|
|||
return datetime.fromtimestamp(ts).isoformat()
|
||||
|
||||
|
||||
def _stat(file_store, path: str) -> dict:
|
||||
target = resolve_path(file_store, path)
|
||||
async def _stat(file_store, path: str) -> dict:
|
||||
try:
|
||||
target = await path_resolver.resolve_to_absolute(file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return {"path": path, "error": "ambiguous", "candidates": e.candidates}
|
||||
except path_resolver.PathNotFound:
|
||||
# Directory or unindexed path — fall back to plain join.
|
||||
target = path_resolver.to_absolute(file_store, path)
|
||||
|
||||
if not target.exists():
|
||||
return {"path": path, "exists": False}
|
||||
|
||||
st = target.stat()
|
||||
out: dict = {
|
||||
"path": path,
|
||||
"path": str(target),
|
||||
"absolute_path": str(target),
|
||||
"exists": True,
|
||||
"type": "dir" if target.is_dir() else "file",
|
||||
"mtime": _iso(st.st_mtime),
|
||||
|
|
@ -78,8 +89,8 @@ class FileStat(BaseStep):
|
|||
assert self.context is not None
|
||||
path: str = self.context.get("path", "") or ""
|
||||
assert path, "path is required"
|
||||
payload = _stat(self.file_store, path)
|
||||
self.context.response.success = payload.get("exists", False)
|
||||
payload = await _stat(self.file_store, path)
|
||||
self.context.response.success = payload.get("exists", False) and "error" not in payload
|
||||
_set_answer(self.context, payload)
|
||||
|
||||
async def file_stat(self, path: str) -> ToolResponse:
|
||||
|
|
@ -89,6 +100,6 @@ class FileStat(BaseStep):
|
|||
``size`` / ``mime`` / ``frontmatter`` are file-only;
|
||||
``frontmatter`` is markdown-only.
|
||||
"""
|
||||
payload = _stat(self.file_store, path)
|
||||
ok = payload.get("exists", False)
|
||||
payload = await _stat(self.file_store, path)
|
||||
ok = payload.get("exists", False) and "error" not in payload
|
||||
return _tool_response("file_stat", ok, payload, audit=self.audit)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""``file_upload`` — copy a local file into the vault.
|
||||
"""``file_upload`` — copy a local file into the vault at a given path.
|
||||
|
||||
Agent flow: agent has a file at some local path (a download result, a
|
||||
freshly generated artifact, a user-supplied attachment), and wants it
|
||||
Used to put materials (or any binary/text artifact) into the vault
|
||||
to live inside the vault at a known location. This is type-agnostic
|
||||
— text, binary, anything — so it's the entry point for non-markdown
|
||||
materials too.
|
||||
|
|
@ -10,6 +9,11 @@ The watcher / parser pick up the new file asynchronously; this step
|
|||
just performs the copy. ``overwrite=True`` is the default since most
|
||||
upload flows are intentional replacements (re-uploading the same
|
||||
material after re-generation).
|
||||
|
||||
``path`` **must be a vault-relative path** with a directory component
|
||||
— short links and absolute paths are rejected. File creation
|
||||
needs an unambiguous primary key, and short links can't promise that
|
||||
without a graph entry. Use ``file_move`` to rename existing files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -20,19 +24,17 @@ from pathlib import Path
|
|||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from .download import resolve_path
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...utils import path_resolver
|
||||
|
||||
|
||||
@R.register("file_upload")
|
||||
class FileUpload(BaseStep):
|
||||
"""Copy a local file into the vault. Watcher / parser register the
|
||||
FileNode asynchronously."""
|
||||
"""Copy ``local_path`` into the vault at ``path``."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
|
|
@ -57,10 +59,14 @@ class FileUpload(BaseStep):
|
|||
return _tool_response("file_upload", ok, payload, audit=self.audit)
|
||||
|
||||
def _upload(self, local_path: str, path: str, overwrite: bool) -> dict:
|
||||
if Path(path).is_absolute():
|
||||
return {"path": path, "error": "path must be vault-relative"}
|
||||
if path_resolver.is_short_path(path):
|
||||
return {"path": path, "error": "path must include a directory component"}
|
||||
src = Path(local_path)
|
||||
if not src.is_file():
|
||||
return {"local_path": local_path, "error": "source not found"}
|
||||
dst = resolve_path(self.file_store, path)
|
||||
dst = path_resolver.to_absolute(self.file_store, path)
|
||||
if dst.exists() and not overwrite:
|
||||
return {"path": path, "error": "destination exists; pass overwrite=True"}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ data we already have.
|
|||
Direction vocabulary accepts both the obsidian convention
|
||||
(``forward`` / ``backward`` / ``both``) and the engine convention
|
||||
(``out`` / ``in`` / ``both``).
|
||||
|
||||
Seeds accept short links / vault-relative paths and go through
|
||||
``path_resolver.resolve`` so the BFS keys match the graph's stored
|
||||
form. Short-link ambiguity surfaces as ``error="ambiguous"`` (with
|
||||
candidates) and the BFS is **not** executed in that case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -30,14 +35,13 @@ from pathlib import Path
|
|||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..crud.download import resolve_path
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...schema import FileLink
|
||||
from ...utils import path_resolver
|
||||
|
||||
|
||||
_FORWARD = {"out", "forward"}
|
||||
|
|
@ -123,13 +127,24 @@ def _bfs(
|
|||
return results
|
||||
|
||||
|
||||
def _normalize_seeds(file_store, raw) -> list[str]:
|
||||
"""Coerce a single path or list of paths into resolved absolute strings."""
|
||||
async def _normalize_seeds(file_store, raw) -> list[str]:
|
||||
"""Resolve seeds (short link / relative path) to vault-relative keys.
|
||||
|
||||
Raises ``PathAmbiguous`` / ``PathNotFound`` from ``path_resolver``;
|
||||
callers wrap to convert into a structured response.
|
||||
"""
|
||||
if isinstance(raw, (str, Path)):
|
||||
items = [raw]
|
||||
else:
|
||||
items = list(raw or [])
|
||||
return [str(resolve_path(file_store, str(p))) for p in items if p]
|
||||
if file_store.file_graph is None:
|
||||
raise path_resolver.PathNotFound("no file_graph configured")
|
||||
out: list[str] = []
|
||||
for p in items:
|
||||
if not p:
|
||||
continue
|
||||
out.append(await path_resolver.resolve(file_store.file_graph, str(p)))
|
||||
return out
|
||||
|
||||
|
||||
@R.register("graph:traverse")
|
||||
|
|
@ -151,14 +166,27 @@ class GraphTraverse(BaseStep):
|
|||
assert self.context is not None
|
||||
path = self.context.get("path")
|
||||
seeds_raw = self.context.get("seeds") if path is None else path
|
||||
seeds = _normalize_seeds(self.file_store, seeds_raw)
|
||||
depth = int(self.context.get("depth") or self.context.get("max_depth") or 1)
|
||||
direction = (self.context.get("direction") or "forward").lower()
|
||||
predicate = self.context.get("predicate")
|
||||
assert seeds, "path (or seeds) is required"
|
||||
assert direction in _VALID_DIRECTIONS, (
|
||||
f"direction must be one of {sorted(_VALID_DIRECTIONS)}, got {direction!r}"
|
||||
)
|
||||
try:
|
||||
seeds = await _normalize_seeds(self.file_store, seeds_raw)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
self.context.response.success = False
|
||||
_set_answer(self.context, {
|
||||
"error": "ambiguous",
|
||||
"target": e.target,
|
||||
"candidates": e.candidates,
|
||||
})
|
||||
return
|
||||
except path_resolver.PathNotFound as e:
|
||||
self.context.response.success = False
|
||||
_set_answer(self.context, {"error": "not found", "target": e.target})
|
||||
return
|
||||
assert seeds, "path (or seeds) is required"
|
||||
outbound, inbound = await _build_indexes(self.file_store)
|
||||
results = _bfs(seeds, depth, direction, predicate, outbound, inbound)
|
||||
_set_answer(self.context, results)
|
||||
|
|
@ -183,7 +211,20 @@ class GraphTraverse(BaseStep):
|
|||
assert direction in _VALID_DIRECTIONS, (
|
||||
f"direction must be one of {sorted(_VALID_DIRECTIONS)}, got {direction!r}"
|
||||
)
|
||||
seeds = _normalize_seeds(self.file_store, path)
|
||||
try:
|
||||
seeds = await _normalize_seeds(self.file_store, path)
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
return _tool_response(
|
||||
"graph:traverse", False,
|
||||
{"error": "ambiguous", "target": e.target, "candidates": e.candidates},
|
||||
audit=self.audit,
|
||||
)
|
||||
except path_resolver.PathNotFound as e:
|
||||
return _tool_response(
|
||||
"graph:traverse", False,
|
||||
{"error": "not found", "target": e.target},
|
||||
audit=self.audit,
|
||||
)
|
||||
assert seeds, "path is required"
|
||||
outbound, inbound = await _build_indexes(self.file_store)
|
||||
results = _bfs(seeds, depth, direction, predicate, outbound, inbound)
|
||||
|
|
|
|||
24
reme2/steps/lint/__init__.py
Normal file
24
reme2/steps/lint/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Lint steps — atomic vault-health checks.
|
||||
|
||||
Read-only diagnostics for maintainer / CLI / scheduled-job use;
|
||||
agents typically don't need them in their per-call working set.
|
||||
|
||||
Four atomic checks, each does one thing and returns pure data:
|
||||
|
||||
lint:dangling — FileLinks pointing to non-existent nodes
|
||||
lint:orphans — nodes with no inlinks AND no outlinks
|
||||
lint:collisions — basenames resolving to >1 path (short-link
|
||||
ambiguity)
|
||||
lint:schema — nodes violating frontmatter schema
|
||||
(missing required fields, invalid status)
|
||||
|
||||
Maintainer compositions live in ``memory/maintainer.py``; this
|
||||
package is the underlying primitives. Each step is also independently
|
||||
MCP/agent callable for ad-hoc checks. Bind via
|
||||
``memory.lint_toolkit.build_lint_toolkit``.
|
||||
"""
|
||||
|
||||
from . import dangling # noqa: F401 -- @R.register("lint:dangling")
|
||||
from . import orphans # noqa: F401 -- @R.register("lint:orphans")
|
||||
from . import collisions # noqa: F401 -- @R.register("lint:collisions")
|
||||
from . import schema # noqa: F401 -- @R.register("lint:schema")
|
||||
55
reme2/steps/lint/collisions.py
Normal file
55
reme2/steps/lint/collisions.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""``lint:collisions`` — find basenames resolving to >1 path.
|
||||
|
||||
Atomic vault-health check: groups every indexed path by its
|
||||
filename + extension, surfaces any basename owned by more than one
|
||||
path. Operates synchronously over the local file_store index — no
|
||||
async graph round-trip needed.
|
||||
|
||||
Returns:
|
||||
|
||||
{count: int, groups: {<basename>: [<path>, ...]}}
|
||||
|
||||
Each path list is sorted for stable output across runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
||||
def _scan_collisions(file_store) -> dict[str, list[str]]:
|
||||
by_name: dict[str, list[str]] = {}
|
||||
for path in file_store.file_nodes:
|
||||
by_name.setdefault(Path(path).name, []).append(path)
|
||||
return {name: sorted(paths) for name, paths in by_name.items() if len(paths) > 1}
|
||||
|
||||
|
||||
@R.register("lint:collisions")
|
||||
class LintCollisions(BaseStep):
|
||||
"""List basenames resolving to >1 path (short-link ambiguity)."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
audit: list[dict] | None = None
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
groups = _scan_collisions(self.file_store)
|
||||
_set_answer(self.context, {"count": len(groups), "groups": groups})
|
||||
|
||||
async def lint_collisions(self) -> ToolResponse:
|
||||
"""List basenames resolving to >1 path (short-link ambiguity)."""
|
||||
groups = _scan_collisions(self.file_store)
|
||||
return _tool_response(
|
||||
"lint:collisions", True,
|
||||
{"count": len(groups), "groups": groups},
|
||||
audit=self.audit,
|
||||
)
|
||||
65
reme2/steps/lint/dangling.py
Normal file
65
reme2/steps/lint/dangling.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""``lint:dangling`` — find FileLinks pointing to non-existent nodes.
|
||||
|
||||
Atomic vault-health check: walks every indexed node's ``links`` and
|
||||
reports each edge whose ``path`` target isn't present in the index.
|
||||
Pure dict iteration over ``file_store.file_nodes`` — no filesystem
|
||||
walk, no graph round-trip.
|
||||
|
||||
Returns:
|
||||
|
||||
{count: int, findings: [
|
||||
{source, target, predicate, anchor},
|
||||
...
|
||||
]}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
||||
def _scan_dangling(file_store) -> list[dict]:
|
||||
"""One entry per dangling edge."""
|
||||
nodes = file_store.file_nodes
|
||||
out: list[dict] = []
|
||||
for src_path, node in nodes.items():
|
||||
for link in node.links:
|
||||
if not link.path:
|
||||
continue
|
||||
if link.path not in nodes:
|
||||
out.append({
|
||||
"source": src_path,
|
||||
"target": link.path,
|
||||
"predicate": link.predicate,
|
||||
"anchor": link.anchor,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@R.register("lint:dangling")
|
||||
class LintDangling(BaseStep):
|
||||
"""List every FileLink whose target is not in the graph."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
audit: list[dict] | None = None
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
findings = _scan_dangling(self.file_store)
|
||||
_set_answer(self.context, {"count": len(findings), "findings": findings})
|
||||
|
||||
async def lint_dangling(self) -> ToolResponse:
|
||||
"""List every FileLink whose target is not in the graph."""
|
||||
findings = _scan_dangling(self.file_store)
|
||||
return _tool_response(
|
||||
"lint:dangling", True,
|
||||
{"count": len(findings), "findings": findings},
|
||||
audit=self.audit,
|
||||
)
|
||||
62
reme2/steps/lint/orphans.py
Normal file
62
reme2/steps/lint/orphans.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""``lint:orphans`` — find nodes with no inlinks AND no outlinks.
|
||||
|
||||
Atomic vault-health check: a node is an orphan if its ``links`` is
|
||||
empty AND no other node references it. O(N + E) — one pass to mark
|
||||
every referenced path, one pass to filter unreferenced + linkless
|
||||
nodes.
|
||||
|
||||
Returns:
|
||||
|
||||
{count: int, paths: [<path>, ...]}
|
||||
|
||||
The ``paths`` list is sorted for stable output across runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
||||
def _scan_orphans(file_store) -> list[str]:
|
||||
nodes = file_store.file_nodes
|
||||
referenced: set[str] = set()
|
||||
for node in nodes.values():
|
||||
for link in node.links:
|
||||
if link.path:
|
||||
referenced.add(link.path)
|
||||
out: list[str] = []
|
||||
for path, node in nodes.items():
|
||||
has_out = any(link.path for link in node.links)
|
||||
has_in = path in referenced
|
||||
if not has_out and not has_in:
|
||||
out.append(path)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
@R.register("lint:orphans")
|
||||
class LintOrphans(BaseStep):
|
||||
"""List nodes with no inlinks AND no outlinks."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
audit: list[dict] | None = None
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
orphans = _scan_orphans(self.file_store)
|
||||
_set_answer(self.context, {"count": len(orphans), "paths": orphans})
|
||||
|
||||
async def lint_orphans(self) -> ToolResponse:
|
||||
"""List nodes with no inlinks AND no outlinks."""
|
||||
orphans = _scan_orphans(self.file_store)
|
||||
return _tool_response(
|
||||
"lint:orphans", True,
|
||||
{"count": len(orphans), "paths": orphans},
|
||||
audit=self.audit,
|
||||
)
|
||||
67
reme2/steps/lint/schema.py
Normal file
67
reme2/steps/lint/schema.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""``lint:schema`` — find nodes whose frontmatter violates the memory schema.
|
||||
|
||||
Atomic vault-health check: required-key presence + ``status`` enum
|
||||
validity. Mirrors the validator that fires inside ``memory_create``;
|
||||
keep ``_REQUIRED_META_KEYS`` and ``_VALID_STATUS`` in sync with the
|
||||
path-template + status-state-machine rules in ``memory_toolkit``.
|
||||
|
||||
Returns:
|
||||
|
||||
{count: int, findings: [
|
||||
{path, errors: [<error_msg>, ...]},
|
||||
...
|
||||
]}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..runtime_response import _set_answer, _tool_response
|
||||
|
||||
from ...component import R
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
||||
_REQUIRED_META_KEYS = ("title", "lifecycle", "scope", "source", "role")
|
||||
_VALID_STATUS = {"active", "distilled", "archived"}
|
||||
|
||||
|
||||
def _scan_schema(file_store) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for path, node in file_store.file_nodes.items():
|
||||
meta = node.front_matter.model_dump()
|
||||
errs: list[str] = []
|
||||
missing = [k for k in _REQUIRED_META_KEYS if not meta.get(k)]
|
||||
if missing:
|
||||
errs.append(f"missing required: {missing}")
|
||||
status = meta.get("status")
|
||||
if status is not None and status not in _VALID_STATUS:
|
||||
errs.append(f"invalid status: {status!r} (expected one of {sorted(_VALID_STATUS)})")
|
||||
if errs:
|
||||
out.append({"path": path, "errors": errs})
|
||||
return out
|
||||
|
||||
|
||||
@R.register("lint:schema")
|
||||
class LintSchema(BaseStep):
|
||||
"""List nodes whose frontmatter violates the memory schema."""
|
||||
|
||||
component_type = ComponentEnum.STEP
|
||||
|
||||
audit: list[dict] | None = None
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
findings = _scan_schema(self.file_store)
|
||||
_set_answer(self.context, {"count": len(findings), "findings": findings})
|
||||
|
||||
async def lint_schema(self) -> ToolResponse:
|
||||
"""List nodes whose frontmatter violates the memory schema."""
|
||||
findings = _scan_schema(self.file_store)
|
||||
return _tool_response(
|
||||
"lint:schema", True,
|
||||
{"count": len(findings), "findings": findings},
|
||||
audit=self.audit,
|
||||
)
|
||||
162
reme2/utils/link_parser.py
Normal file
162
reme2/utils/link_parser.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Link parser — wikilink syntax extraction from text.
|
||||
|
||||
A **Link** is a textual reference to a vault file (``[[X]]`` and
|
||||
typed/anchored variants). This module is the syntax layer:
|
||||
text → ``FileLink`` records.
|
||||
|
||||
``FileLink.path`` here is the **raw target as written** (no
|
||||
``.md`` completion, no resolution). To turn the raw target into a
|
||||
vault-relative key, call ``resolve_links`` (or ``text_to_links`` for
|
||||
one-shot extract+resolve), which delegates to ``path_resolver``.
|
||||
|
||||
## Inline forms recognised
|
||||
|
||||
[[X]] bare wikilink → predicate=None
|
||||
extends:: [[X]] line-level Dataview → predicate="extends"
|
||||
[extends:: [[X]]] inline-bracketed → predicate="extends"
|
||||
|
||||
Multi-target — every wikilink under one typed context inherits its
|
||||
predicate (any separator works):
|
||||
|
||||
extends:: [[A]], [[B]] → 2 links, both "extends"
|
||||
[concerns:: [[A]] and [[B]]] → 2 links, both "concerns"
|
||||
extends:: [[A#s1]], [[B#s2]] → anchors preserved per link
|
||||
|
||||
Context precedence: **inline-bracketed > line-level > bare**.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
from ..component.file_graph.base_file_graph import BaseFileGraph
|
||||
from ..schema import FileLink
|
||||
from . import path_resolver
|
||||
|
||||
|
||||
WIKILINK_RE = re.compile(
|
||||
r"""
|
||||
(?:!)?
|
||||
\[\[
|
||||
(?P<target>[^\]\|\#\n]+?)
|
||||
(?:\#(?P<anchor>[^\]\|\n]+))?
|
||||
(?:\|[^\]\n]+)?
|
||||
\]\]
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
_DATAVIEW_LINE_RE = re.compile(
|
||||
r"^[ \t]*(?:[-*+][ \t]+)?(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*(?P<value>.+?)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_INLINE_FIELD_OPEN_RE = re.compile(r"\[(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*")
|
||||
|
||||
|
||||
def _iter_inline_fields(text: str) -> list[tuple[int, int, str]]:
|
||||
"""Find inline-bracketed ``[predicate:: …]`` field spans by depth scan."""
|
||||
out: list[tuple[int, int, str]] = []
|
||||
for m in _INLINE_FIELD_OPEN_RE.finditer(text):
|
||||
depth = 1
|
||||
i = m.end()
|
||||
n = len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c == "\n":
|
||||
break
|
||||
if c == "[":
|
||||
depth += 1
|
||||
elif c == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
out.append((m.start(), i + 1, m.group("predicate")))
|
||||
break
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def _predicate_for(
|
||||
text: str,
|
||||
pos: int,
|
||||
inline_spans: list[tuple[int, int, str]],
|
||||
) -> str | None:
|
||||
"""Resolve the predicate governing a wikilink at offset ``pos``."""
|
||||
for field_start, field_end, predicate in inline_spans:
|
||||
if field_start <= pos < field_end:
|
||||
return predicate
|
||||
line_start = text.rfind("\n", 0, pos) + 1
|
||||
line_end = text.find("\n", pos)
|
||||
if line_end == -1:
|
||||
line_end = len(text)
|
||||
m = _DATAVIEW_LINE_RE.match(text[line_start:line_end])
|
||||
if m and line_start + m.start("value") <= pos:
|
||||
return m.group("predicate")
|
||||
return None
|
||||
|
||||
|
||||
def iter_links(text: str) -> list[FileLink]:
|
||||
"""Extract every wikilink in ``text`` as a pre-resolution ``FileLink``.
|
||||
|
||||
``FileLink.path`` is the raw target as written (no ``.md``
|
||||
completion, no graph lookup); ``anchor`` and ``predicate`` are
|
||||
parsed from surrounding syntax. Pure function — no graph access.
|
||||
Pass through ``resolve_links`` (or ``text_to_links``) to get the
|
||||
vault-relative form.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
inline_spans = _iter_inline_fields(text)
|
||||
links: list[FileLink] = []
|
||||
for wm in WIKILINK_RE.finditer(text):
|
||||
target = wm.group("target").strip()
|
||||
anchor_raw = wm.group("anchor")
|
||||
anchor = anchor_raw.strip() if anchor_raw else ""
|
||||
links.append(FileLink(
|
||||
path=target,
|
||||
anchor=anchor or None,
|
||||
predicate=_predicate_for(text, wm.start(), inline_spans),
|
||||
))
|
||||
return links
|
||||
|
||||
|
||||
async def resolve_links(
|
||||
graph: BaseFileGraph, links: Iterable[FileLink],
|
||||
) -> list[FileLink]:
|
||||
"""Resolve each ``FileLink.path`` via ``path_resolver``.
|
||||
|
||||
Parser-pipeline semantics — short-path ambiguity **expands** into
|
||||
one ``FileLink`` per candidate (so the body's wikilink is recorded
|
||||
against every plausible target). Dangling links are dropped.
|
||||
|
||||
Operation-time callers that need a single primary key should call
|
||||
``path_resolver.resolve`` directly and let ``PathAmbiguous``
|
||||
propagate.
|
||||
"""
|
||||
out: list[FileLink] = []
|
||||
for link in links:
|
||||
if not link.path:
|
||||
continue
|
||||
try:
|
||||
resolved = await path_resolver.resolve(graph, link.path)
|
||||
out.append(FileLink(
|
||||
path=resolved,
|
||||
anchor=link.anchor,
|
||||
predicate=link.predicate,
|
||||
))
|
||||
except path_resolver.PathAmbiguous as e:
|
||||
for c in e.candidates:
|
||||
out.append(FileLink(
|
||||
path=c,
|
||||
anchor=link.anchor,
|
||||
predicate=link.predicate,
|
||||
))
|
||||
except path_resolver.PathNotFound:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
async def text_to_links(graph: BaseFileGraph, text: str) -> list[FileLink]:
|
||||
"""One-shot ``iter_links`` + ``resolve_links`` for the parser pipeline."""
|
||||
return await resolve_links(graph, iter_links(text))
|
||||
190
reme2/utils/path_resolver.py
Normal file
190
reme2/utils/path_resolver.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""Path resolver — single source of truth for vault path resolution.
|
||||
|
||||
Vault paths come in two forms:
|
||||
|
||||
* **relative path** — the canonical primary key for each file
|
||||
(e.g. ``"topics/Alice/Alice.md"``). One path, one file.
|
||||
* **short path** — a simplification with no directory component
|
||||
(e.g. ``"Alice"``, ``"Alice.md"``). Convenient to type, but
|
||||
**may map to multiple relative paths** when more than one file
|
||||
shares the basename.
|
||||
|
||||
Two layers of resolution:
|
||||
|
||||
Layer 1 — vault key (graph-backed)
|
||||
``resolve(graph, path)`` — short path / relative path → the
|
||||
canonical vault-relative primary key. Raises ``PathAmbiguous``
|
||||
on short-link multi-match, ``PathNotFound`` on miss.
|
||||
|
||||
Layer 2 — disk path (file_store-backed)
|
||||
``to_absolute(file_store, relative_path)`` — vault-relative
|
||||
path → absolute on-disk ``Path``. Pure ``working_dir`` join.
|
||||
|
||||
Combined — file access
|
||||
``resolve_to_absolute(file_store, path)`` — Layer 1 + Layer 2.
|
||||
The standard entry point for any operation that **accesses an
|
||||
existing file** (read, update, delete). Short-link callers get
|
||||
ambiguity errors; relative-path callers get plain join.
|
||||
|
||||
For **file creation** callers must pass a vault-relative path
|
||||
(use ``to_absolute`` directly) — short links are meaningless until a
|
||||
file exists.
|
||||
|
||||
This module is the bottom of the path stack — it knows nothing about
|
||||
``FileLink`` or text syntax. Wikilink/Link concerns live in
|
||||
``link_parser``, which calls into here.
|
||||
|
||||
## Conventions applied here
|
||||
|
||||
1. Implicit ``.md`` — a path whose last segment has no extension
|
||||
is completed to ``X.md``. ``image.png`` is left alone.
|
||||
2. Folder-note rule — when both ``X.md`` and ``X/X.md`` exist,
|
||||
the folder-note (``X/X.md``) wins as the canonical resolution
|
||||
for ``X``. Other basename collisions stay ambiguous.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..component.file_graph.base_file_graph import BaseFileGraph
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Exceptions
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class PathError(Exception):
|
||||
"""Base for path-resolution failures."""
|
||||
|
||||
|
||||
class PathNotFound(PathError):
|
||||
"""No node matches the given path."""
|
||||
|
||||
def __init__(self, target: str):
|
||||
super().__init__(f"path not in vault: {target!r}")
|
||||
self.target = target
|
||||
|
||||
|
||||
class PathAmbiguous(PathError):
|
||||
"""A short path matches more than one relative path.
|
||||
|
||||
Caller must qualify the path (add directory components) or pick
|
||||
one of ``self.candidates`` explicitly. Step callers should surface
|
||||
``self.candidates`` to the user and abort the operation.
|
||||
"""
|
||||
|
||||
def __init__(self, target: str, candidates: list[str]):
|
||||
super().__init__(f"path {target!r} is ambiguous: {candidates}")
|
||||
self.target = target
|
||||
self.candidates = list(candidates)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Predicates
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def is_short_path(path: str) -> bool:
|
||||
"""``True`` when ``path`` has no directory component (short form)."""
|
||||
return bool(path) and "/" not in path
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Internal helpers
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _complete(path: str) -> str:
|
||||
"""Apply the implicit ``.md`` rule to ``path``."""
|
||||
if not path:
|
||||
return path
|
||||
last = path.rsplit("/", 1)[-1]
|
||||
return path if "." in last else path + ".md"
|
||||
|
||||
|
||||
def _filter_folder_note(basename: str, paths: list[str]) -> list[str]:
|
||||
"""Apply folder-note rule. Sorted for determinism."""
|
||||
if not paths:
|
||||
return []
|
||||
stem = Path(basename).stem
|
||||
folder_hits = sorted(p for p in paths if Path(p).parent.name == stem)
|
||||
return folder_hits or sorted(paths)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Layer 1 — graph-backed vault key resolution
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def resolve(graph: BaseFileGraph, path: str) -> str:
|
||||
"""Resolve ``path`` to a single vault-relative key. Raises on failure.
|
||||
|
||||
Applies implicit ``.md`` completion before lookup. Dispatches by
|
||||
shape:
|
||||
* literal path (contains ``/``) → direct ``get_nodes`` lookup
|
||||
* short path (no ``/``) → basename match + folder-note rule
|
||||
|
||||
Raises:
|
||||
``PathNotFound`` — no matching node.
|
||||
``PathAmbiguous`` — short path with multiple matches.
|
||||
"""
|
||||
if not path:
|
||||
raise PathNotFound(path)
|
||||
target = _complete(path)
|
||||
|
||||
if not is_short_path(target):
|
||||
if await graph.get_nodes([target]):
|
||||
return target
|
||||
raise PathNotFound(target)
|
||||
|
||||
matches = [n.path for n in await graph.get_nodes() if Path(n.path).name == target]
|
||||
candidates = _filter_folder_note(target, matches)
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if candidates:
|
||||
raise PathAmbiguous(target, candidates)
|
||||
raise PathNotFound(target)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Layer 2 — filesystem path composition
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def to_absolute(file_store, relative_path: str) -> Path:
|
||||
"""Compose absolute on-disk ``Path`` from a vault-relative path.
|
||||
|
||||
``relative_path`` should be vault-relative; absolute inputs pass
|
||||
through unchanged (Python's ``Path / abs`` join). For paths that
|
||||
may be short links, route through ``resolve_to_absolute`` instead
|
||||
so ambiguity surfaces.
|
||||
"""
|
||||
working_dir = getattr(file_store, "working_dir", None) or "."
|
||||
return (Path(working_dir) / relative_path).resolve()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Combined — file access
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def resolve_to_absolute(file_store, path: str) -> Path:
|
||||
"""User path → absolute on-disk ``Path`` (the file-access entry point).
|
||||
|
||||
Resolves short links / relative paths via ``resolve`` (graph), then
|
||||
composes the absolute ``Path`` via ``to_absolute``. Any operation
|
||||
that touches an existing vault file should go through here so
|
||||
short-link ambiguity surfaces as ``PathAmbiguous`` (with candidates)
|
||||
rather than silently picking a wrong file.
|
||||
|
||||
Raises:
|
||||
``PathNotFound`` — no matching node.
|
||||
``PathAmbiguous`` — short path with multiple matches.
|
||||
"""
|
||||
graph = getattr(file_store, "file_graph", None)
|
||||
if graph is None:
|
||||
raise PathNotFound(path)
|
||||
relative = await resolve(graph, path)
|
||||
return to_absolute(file_store, relative)
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
"""Wikilink syntax + resolver — vault convention over ``BaseFileGraph``.
|
||||
|
||||
This module is the single home for wikilink **syntax** (regex
|
||||
extraction, predicate detection) and wikilink **resolution** (mapping
|
||||
raw targets to vault-relative paths via the file_graph).
|
||||
|
||||
The ``FileLink`` schema (see ``schema.file_link``) is just a typed
|
||||
record; here we define how it's produced from text and resolved
|
||||
against the graph.
|
||||
|
||||
## Two-layer link semantics
|
||||
|
||||
1. Implicit extension — ``[[Foo]]`` has no extension on its last
|
||||
segment, so it's completed to ``Foo.md`` (markdown is the
|
||||
default vault content type). ``[[image.png]]`` already has an
|
||||
extension; left as-is. Done in ``iter_links`` at extraction
|
||||
time, so all FileLinks emerge with extension-bearing paths.
|
||||
|
||||
2. Short link — a path with no ``/`` (after implicit completion)
|
||||
is matched against the basename of every node in the graph.
|
||||
The folder-note rule applies: when both ``X.md`` and
|
||||
``X/X.md`` exist, the folder-note wins. Ambiguity expands into
|
||||
multiple FileLink records (one per candidate path). Targets
|
||||
containing ``/`` are treated as literal paths and looked up
|
||||
directly.
|
||||
|
||||
So ``[[Foo]]`` → ``Foo.md`` (implicit) → search basename ``Foo.md``
|
||||
across the vault, returning e.g. ``topics/Foo/Foo.md``;
|
||||
``[[topics/Bar]]`` → ``topics/Bar.md`` (implicit) → literal lookup;
|
||||
``[[image.png]]`` → ``image.png`` (no completion needed) → search
|
||||
basename ``image.png``; ``[[topics/image.png]]`` → literal lookup.
|
||||
|
||||
All paths are vault-relative — ``graph.iter_nodes()`` returns the
|
||||
key form file_graph stores, and emitted ``FileLink.path`` matches.
|
||||
|
||||
All resolver functions are stateless — they walk ``graph.iter_nodes()``
|
||||
per call. For batch operations (``resolve_links``) the basename index
|
||||
is built once and reused.
|
||||
|
||||
## Inline forms recognised by ``iter_links``
|
||||
|
||||
[[X]] bare wikilink → predicate=None
|
||||
extends:: [[X]] line-level Dataview → predicate="extends"
|
||||
[extends:: [[X]]] inline-bracketed → predicate="extends"
|
||||
|
||||
Multi-target — every wikilink under one typed context inherits its
|
||||
predicate (any separator works, not just commas):
|
||||
|
||||
extends:: [[A]], [[B]] line-level multi → 2 links, both "extends"
|
||||
extends:: [[A]] and [[B]] prose-style multi → 2 links, both "extends"
|
||||
[concerns:: [[A]], [[B]]] inline multi → 2 links, both "concerns"
|
||||
extends:: [[A#s1]], [[B#s2]] multi w/ anchors → anchors preserved per link
|
||||
|
||||
Context precedence is **inline-bracketed > line-level > bare** —
|
||||
a wikilink inside a ``[predicate:: …]`` envelope is typed by that
|
||||
envelope even if the line happens to start ``predicate:: …``.
|
||||
|
||||
## Entry points
|
||||
|
||||
Extraction (no graph)
|
||||
iter_links text → [FileLink] (path = target after implicit .md)
|
||||
extract_wikilinks text → [str] (raw target list, no completion)
|
||||
|
||||
Resolution (graph-backed)
|
||||
resolve single link → path | None
|
||||
used by: ``extract_anchors``, ``memory_resolve_wikilink``
|
||||
candidates target → [path] (folder-note ordered first)
|
||||
used by: ``memory_resolve_wikilink`` (ambiguity report)
|
||||
collisions all basenames with >1 path
|
||||
used by: ``maintainer.lint``
|
||||
collisions_for paths conflicting with a proposed new path
|
||||
used by: ``memory_create``, ``sync`` (preflight)
|
||||
extract_anchors parse [[X]] from text + resolve, dedup
|
||||
used by: ``retriever`` (query anchor seeds)
|
||||
resolve_links ``[FileLink]`` (raw path) → ``[FileLink]`` (resolved)
|
||||
core resolution; short-link ambiguity expands
|
||||
text_to_links one-shot: ``iter_links(text)`` + ``resolve_links``
|
||||
used by: parser pipeline (before ``upsert_node``)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from ..component.file_graph.base_file_graph import BaseFileGraph
|
||||
from ..schema import FileLink
|
||||
from .logger_utils import get_logger
|
||||
|
||||
|
||||
_logger = get_logger()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Wikilink syntax — regex extraction + predicate detection
|
||||
# =========================================================================
|
||||
|
||||
|
||||
_WIKILINK_RE = re.compile(
|
||||
r"""
|
||||
(?:!)?
|
||||
\[\[
|
||||
(?P<target>[^\]\|\#\n]+?)
|
||||
(?:\#(?P<anchor>[^\]\|\n]+))?
|
||||
(?:\|[^\]\n]+)?
|
||||
\]\]
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
_DATAVIEW_LINE_RE = re.compile(
|
||||
r"^[ \t]*(?:[-*+][ \t]+)?(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*(?P<value>.+?)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_INLINE_FIELD_OPEN_RE = re.compile(r"\[(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*")
|
||||
|
||||
|
||||
def _iter_inline_fields(text: str) -> list[tuple[int, int, str]]:
|
||||
"""Find inline-bracketed ``[predicate:: …]`` field spans by depth scan."""
|
||||
out: list[tuple[int, int, str]] = []
|
||||
for m in _INLINE_FIELD_OPEN_RE.finditer(text):
|
||||
depth = 1
|
||||
i = m.end()
|
||||
n = len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c == "\n":
|
||||
break
|
||||
if c == "[":
|
||||
depth += 1
|
||||
elif c == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
out.append((m.start(), i + 1, m.group("predicate")))
|
||||
break
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def _predicate_for(
|
||||
text: str,
|
||||
pos: int,
|
||||
inline_spans: list[tuple[int, int, str]],
|
||||
) -> str | None:
|
||||
"""Resolve the predicate governing a wikilink at offset ``pos``."""
|
||||
for field_start, field_end, predicate in inline_spans:
|
||||
if field_start <= pos < field_end:
|
||||
return predicate
|
||||
line_start = text.rfind("\n", 0, pos) + 1
|
||||
line_end = text.find("\n", pos)
|
||||
if line_end == -1:
|
||||
line_end = len(text)
|
||||
m = _DATAVIEW_LINE_RE.match(text[line_start:line_end])
|
||||
if m and line_start + m.start("value") <= pos:
|
||||
return m.group("predicate")
|
||||
return None
|
||||
|
||||
|
||||
def _complete_path(target: str) -> str:
|
||||
"""Append ``.md`` if the last path segment has no extension.
|
||||
|
||||
Implements the implicit-markdown rule: ``[[Foo]]`` → ``Foo.md``,
|
||||
``[[topics/Bar]]`` → ``topics/Bar.md``, but ``[[image.png]]`` is
|
||||
left alone. ``"."`` in the last segment counts as "has extension".
|
||||
"""
|
||||
if not target:
|
||||
return target
|
||||
last = target.rsplit("/", 1)[-1]
|
||||
if "." in last:
|
||||
return target
|
||||
return target + ".md"
|
||||
|
||||
|
||||
def iter_links(text: str) -> list[FileLink]:
|
||||
"""Extract every wikilink in ``text`` as a pre-resolution ``FileLink``.
|
||||
|
||||
Each emitted ``FileLink`` has ``path`` set to the wikilink target
|
||||
with the implicit ``.md`` rule applied (so ``[[Foo]]`` emerges as
|
||||
``path="Foo.md"``); the ``#anchor`` lives in its own field.
|
||||
Predicate is decided by surrounding context with precedence
|
||||
**inline-bracketed > line-level > bare**.
|
||||
|
||||
Pure function: no graph access. Pass the result through
|
||||
``resolve_links`` (or one-shot ``text_to_links``) to apply
|
||||
short-link resolution and get the form file_graph stores.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
inline_spans = _iter_inline_fields(text)
|
||||
links: list[FileLink] = []
|
||||
for wm in _WIKILINK_RE.finditer(text):
|
||||
target = wm.group("target").strip()
|
||||
anchor_raw = wm.group("anchor")
|
||||
anchor = anchor_raw.strip() if anchor_raw else ""
|
||||
links.append(FileLink(
|
||||
path=_complete_path(target),
|
||||
anchor=anchor or None,
|
||||
predicate=_predicate_for(text, wm.start(), inline_spans),
|
||||
))
|
||||
return links
|
||||
|
||||
|
||||
def extract_wikilinks(text: str) -> list[str]:
|
||||
"""Flat list of wikilink **file targets** in body text (no dedup).
|
||||
|
||||
Returns just the file part of each wikilink **as written** —
|
||||
no implicit ``.md`` completion (callers like ``resolve`` apply
|
||||
completion themselves). Single regex pass — cheaper than
|
||||
``iter_links`` when callers don't need predicates.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
return [m.group("target").strip() for m in _WIKILINK_RE.finditer(text)]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Resolution helpers (internal)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def _build_basename_index(graph: BaseFileGraph) -> dict[str, list[str]]:
|
||||
"""Walk once, group paths by basename (file name with extension).
|
||||
|
||||
Used by short-link resolution batch hot paths.
|
||||
"""
|
||||
out: dict[str, list[str]] = {}
|
||||
async for path, _ in graph.iter_nodes():
|
||||
out.setdefault(Path(path).name, []).append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _split_link(link: str) -> tuple[str, str]:
|
||||
"""Return ``(target, anchor)``. Anchor empty if no ``#``.
|
||||
|
||||
For raw link strings supplied by external callers (which may still
|
||||
arrive in ``[[A#section]]`` form). Pre-extracted ``FileLink``
|
||||
records already carry ``path`` and ``anchor`` separately.
|
||||
"""
|
||||
if not link:
|
||||
return "", ""
|
||||
if "#" not in link:
|
||||
return link.strip(), ""
|
||||
target_raw, anchor_raw = link.split("#", 1)
|
||||
return target_raw.strip(), anchor_raw.strip()
|
||||
|
||||
|
||||
def _has_dir(target: str) -> bool:
|
||||
"""``True`` for literal-path targets (containing ``/``);
|
||||
``False`` for short links (basename only).
|
||||
"""
|
||||
return "/" in target
|
||||
|
||||
|
||||
def _filter_short_link_candidates(basename: str, paths: list[str]) -> list[str]:
|
||||
"""Apply folder-note rule to short-link basename matches.
|
||||
|
||||
If any path is a folder-note (parent dir name == file stem) the
|
||||
folder-notes win as the only candidates; otherwise all matches
|
||||
are returned. Sorted for determinism.
|
||||
"""
|
||||
if not paths:
|
||||
return []
|
||||
stem = Path(basename).stem
|
||||
folder_hits = sorted(p for p in paths if Path(p).parent.name == stem)
|
||||
if folder_hits:
|
||||
return folder_hits
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Public API — single-shot lookups
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def resolve(graph: BaseFileGraph, link: str) -> str | None:
|
||||
"""Resolve a single wikilink to **one** vault-relative path, or None.
|
||||
|
||||
Applies implicit ``.md`` completion, then dispatches:
|
||||
* literal path (contains ``/``) → direct ``get_node`` lookup
|
||||
* short link (no ``/``) → basename match + folder-note rule
|
||||
|
||||
Returns None if dangling or ambiguous (with warning on ambiguity).
|
||||
Use ``resolve_links`` for the multi-link expansion semantics.
|
||||
"""
|
||||
target, _ = _split_link(link)
|
||||
if not target:
|
||||
return None
|
||||
target = _complete_path(target)
|
||||
|
||||
if _has_dir(target):
|
||||
return target if await graph.get_node(target) else None
|
||||
|
||||
paths = [
|
||||
p async for p, _ in graph.iter_nodes() if Path(p).name == target
|
||||
]
|
||||
candidates_for = _filter_short_link_candidates(target, paths)
|
||||
if len(candidates_for) == 1:
|
||||
return candidates_for[0]
|
||||
if len(candidates_for) > 1:
|
||||
_logger.warning(
|
||||
f"Wikilink [[{target}]] is ambiguous, "
|
||||
f"candidates: {candidates_for}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def candidates(graph: BaseFileGraph, target: str) -> list[str]:
|
||||
"""All vault paths a ``[[target]]`` could match.
|
||||
|
||||
Applies implicit ``.md`` completion. For literal paths (with ``/``)
|
||||
returns ``[target]`` if it exists else ``[]``. For short links
|
||||
returns every node whose basename matches, with folder-note hits
|
||||
ordered first.
|
||||
"""
|
||||
target = _complete_path(target)
|
||||
if _has_dir(target):
|
||||
return [target] if await graph.get_node(target) else []
|
||||
|
||||
stem = Path(target).stem
|
||||
folder_hits: list[str] = []
|
||||
name_hits: list[str] = []
|
||||
async for path, _ in graph.iter_nodes():
|
||||
if Path(path).name != target:
|
||||
continue
|
||||
if Path(path).parent.name == stem:
|
||||
folder_hits.append(path)
|
||||
else:
|
||||
name_hits.append(path)
|
||||
if folder_hits:
|
||||
return sorted(folder_hits)
|
||||
return sorted(name_hits)
|
||||
|
||||
|
||||
async def collisions(graph: BaseFileGraph) -> dict[str, list[str]]:
|
||||
"""Every basename that resolves to >1 path. Used by maintainer.lint.
|
||||
|
||||
Reflects short-link ambiguity: ``[[X.md]]`` (or ``[[X]]``) hitting
|
||||
multiple files in different directories.
|
||||
"""
|
||||
basename_index = await _build_basename_index(graph)
|
||||
return {
|
||||
name: sorted(paths)
|
||||
for name, paths in basename_index.items()
|
||||
if len(paths) > 1
|
||||
}
|
||||
|
||||
|
||||
async def collisions_for(
|
||||
graph: BaseFileGraph, proposed_path: str | Path,
|
||||
) -> list[str]:
|
||||
"""Existing paths that would conflict with adding ``proposed_path``.
|
||||
|
||||
``proposed_path`` is vault-relative (matches ``graph.iter_nodes()``).
|
||||
Folder-note rule: when the proposed path is itself a folder-note
|
||||
(parent dir name == file stem), only colliding folder-notes are
|
||||
returned. Otherwise all paths sharing the basename are returned.
|
||||
"""
|
||||
p = Path(proposed_path)
|
||||
name = p.name
|
||||
stem = p.stem
|
||||
proposed_str = str(p)
|
||||
is_folder_note = p.parent.name == stem
|
||||
|
||||
folder_hits: list[str] = []
|
||||
name_hits: list[str] = []
|
||||
async for path, _ in graph.iter_nodes():
|
||||
if path == proposed_str:
|
||||
continue
|
||||
path_obj = Path(path)
|
||||
if path_obj.name != name:
|
||||
continue
|
||||
if path_obj.parent.name == stem:
|
||||
folder_hits.append(path)
|
||||
else:
|
||||
name_hits.append(path)
|
||||
|
||||
if is_folder_note:
|
||||
return sorted(folder_hits)
|
||||
return sorted(folder_hits) + sorted(name_hits)
|
||||
|
||||
|
||||
async def extract_anchors(graph: BaseFileGraph, text: str) -> list[str]:
|
||||
"""Pull ``[[X]]`` from ``text``, resolve each, dedup in source order.
|
||||
|
||||
Uses single-target ``resolve`` semantics — ambiguous short links
|
||||
return no anchor. (For multi-link expansion at *write* time, see
|
||||
``resolve_links``; ``extract_anchors`` is for read-time seeding
|
||||
where a single deterministic target is wanted.)
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for raw in extract_wikilinks(text):
|
||||
hit = await resolve(graph, raw)
|
||||
if hit is not None and hit not in seen:
|
||||
seen.add(hit)
|
||||
out.append(hit)
|
||||
return out
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Public API — parser pipeline
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def resolve_links(
|
||||
graph: BaseFileGraph, links: Iterable[FileLink],
|
||||
) -> list[FileLink]:
|
||||
"""Resolve pre-resolution ``FileLink`` records against the graph.
|
||||
|
||||
Each input link has ``path`` already passed through
|
||||
``_complete_path`` (so it has an extension). Returns FileLinks
|
||||
with ``path`` rewritten to the vault-relative resolved form
|
||||
file_graph stores. ``anchor`` and ``predicate`` pass through
|
||||
unchanged.
|
||||
|
||||
Resolution dispatch:
|
||||
* literal path (contains ``/``) → direct ``get_node`` lookup;
|
||||
keep if exists, drop if dangling.
|
||||
* short link (no ``/``) → basename match + folder-note
|
||||
rule. Ambiguity expands into multiple FileLinks (one per
|
||||
candidate path); dangling produces zero.
|
||||
|
||||
Output cardinality per input link:
|
||||
|
||||
* literal, target indexed → 1 link
|
||||
* literal, dangling → 0 links
|
||||
* short, 1 folder-note hit → 1 link
|
||||
* short, N folder-note hits → N links (one per)
|
||||
* short, 0 folder-notes, 1 basename hit → 1 link
|
||||
* short, 0 folder-notes, N basename hits → N links (one per)
|
||||
* short, dangling → 0 links
|
||||
|
||||
Build the basename index lazily: only paid if at least one input
|
||||
is a short link.
|
||||
"""
|
||||
link_list = list(links)
|
||||
if not link_list:
|
||||
return []
|
||||
|
||||
basename_index: dict[str, list[str]] | None = None
|
||||
out: list[FileLink] = []
|
||||
for link in link_list:
|
||||
target = link.path
|
||||
if not target:
|
||||
continue
|
||||
|
||||
if _has_dir(target):
|
||||
if await graph.get_node(target) is None:
|
||||
continue
|
||||
out.append(FileLink(
|
||||
path=target,
|
||||
anchor=link.anchor,
|
||||
predicate=link.predicate,
|
||||
))
|
||||
continue
|
||||
|
||||
# Short link — may expand into multiple links.
|
||||
if basename_index is None:
|
||||
basename_index = await _build_basename_index(graph)
|
||||
for chosen in _filter_short_link_candidates(target, basename_index.get(target, [])):
|
||||
out.append(FileLink(
|
||||
path=chosen,
|
||||
anchor=link.anchor,
|
||||
predicate=link.predicate,
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def text_to_links(
|
||||
graph: BaseFileGraph, text: str,
|
||||
) -> list[FileLink]:
|
||||
"""One-shot: extract wikilinks from ``text`` and resolve to safe links.
|
||||
|
||||
Equivalent to ``await resolve_links(graph, iter_links(text))``.
|
||||
The parser pipeline's single-call entry point.
|
||||
"""
|
||||
return await resolve_links(graph, iter_links(text))
|
||||
Loading…
Add table
Reference in a new issue