fix(memory): the link graph keys entries by path too, not by filename (#1545)

#1545 fixed the MEMORY.md index row; the semantic-link block beside it keyed
candidates by path.name for the same reason and kept the same collision. Under
VT_MEMORY_HIERARCHY two entries sharing a title live at
"{memory_type}/{slug}.md", so discover_links() excluded the OTHER entry as
"self" (it excludes by that key) and any target it wrote named a file that
existed twice.

The reader side accepts all three shapes a sidecar can hold: a relative path as
written from now on, an absolute path as older callers wrote, and a bare
filename, which can only ever be matched by stem.
This commit is contained in:
Haozhe Wu committed 2026-09-23 11:37:09 +08:00
1 parent d1605bcdc6
commit 06fa39f766
2 files changed
+60 -6

No files matched your search

+29 -6
View File
@@ -438,16 +438,31 @@ class PersistentMemory:
linker = SemanticLinker(self._dir)
all_entries = self._scan_entries()
linked_ids: set[str] = set()
linked_paths: set[str] = set()
for r in results:
relations = linker.load_relations(r.path)
for target_file, _score in relations:
linked_ids.add(Path(target_file).stem)
# A target written since the hierarchy fix is the path
# relative to the memory dir; older sidecars hold a
# bare filename or an absolute path, and a bare one can
# only ever be matched by stem.
if "/" in target_file or "\\" in target_file:
linked_paths.add(Path(target_file).as_posix())
else:
linked_ids.add(Path(target_file).stem)
# Add linked entries not already in results
result_paths = {r.path for r in results}
for entry in all_entries:
if len(results) >= max_results:
break
if entry.path.stem in linked_ids and entry.path not in result_paths:
if entry.path in result_paths:
continue
relative = entry.path.relative_to(self._dir).as_posix()
if (
relative in linked_paths
or entry.path.as_posix() in linked_paths
or entry.path.stem in linked_ids
):
results.append(entry)
except Exception:
logger.debug("semantic link expansion failed", exc_info=True)
@@ -571,14 +586,22 @@ class PersistentMemory:
entry_tokens = _tokenize_for_bm25(
f"{new_entry.title} {new_entry.description} {new_entry.body}"
)
# Same key as the index row above, for the same
# reason: under hierarchy mode two entries sharing a
# title have the same path.name, so keying the link
# graph by it made discover_links() drop the OTHER
# entry as "self" and mint an ambiguous target.
all_entries_data = [
(e.path.name, _tokenize_for_bm25(
f"{e.title} {e.description} {e.body}"
))
(e.path.relative_to(self._dir).as_posix(),
_tokenize_for_bm25(
f"{e.title} {e.description} {e.body}"
))
for e in all_entries if e.path != path
]
links = linker.discover_links(
entry_title=new_entry.path.name,
entry_title=new_entry.path.relative_to(
self._dir
).as_posix(),
entry_tokens=entry_tokens,
all_entries_data=all_entries_data,
)
+31
View File
@@ -317,6 +317,37 @@ class TestAdd:
assert "reference/kb-entry.md" in index
assert "user/kb-entry.md" in index
def test_hierarchy_same_title_is_a_link_candidate_not_self(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
# The link graph keyed candidates by path.name too, so under
# VT_MEMORY_HIERARCHY the OTHER entry sharing this title was dropped
# from the corpus as "self" (discover_links excludes by that key) and
# any target it did write was ambiguous between the two files.
monkeypatch.setenv("VT_MEMORY_HIERARCHY", "1")
monkeypatch.setenv("VT_MEMORY_LINKS", "true")
pm = PersistentMemory(memory_dir=tmp_path)
pm.add(
"kb-entry",
"tencent kline pagination walks backward from end",
"reference",
description="tencent kline pagination reference",
)
second = pm.add(
"kb-entry",
"tencent kline pagination walks backward from end",
"user",
description="tencent kline pagination preference",
)
assert second is not None
from src.memory.semantic_links import SemanticLinker
targets = [
target for target, _score in SemanticLinker(tmp_path).load_relations(second)
]
assert "reference/kb-entry.md" in targets
def test_recovered_orphan_and_new_write_agree_on_the_same_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None: