From 708a5d9331b2156a78b80dcc3dddcfc01c885d64 Mon Sep 17 00:00:00 2001 From: Elon-Jiang <191222665+Elon-Jiang@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:25:59 +0800 Subject: [PATCH] fix(memory): keep distinct topic pointers during consolidation --- CHANGELOG.md | 6 ++++++ CHANGELOG_ZH.md | 5 +++++ core/harness/memory.py | 20 +++++++++++-------- tests/test_memory_layers.py | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27740df1e..bb294eab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Released versions are named in the entry title; the version on PyPI is [deepcode-hku](https://pypi.org/project/deepcode-hku/). 中文版见 [CHANGELOG_ZH.md](CHANGELOG_ZH.md). +## 2026-10-31 · Keep distinct memory topics in the index + +- **Preserve different topic links during memory consolidation.** A filename + prefix or a `|` inside a title or target no longer makes separate pointers + collapse into one; repeated links still merge and prose still deduplicates. + ## 2026-10-29 · Lighter compaction, checked Goal completion, and folder browsing on the Web - **Clear old tool output first.** With `agents.defaults.microcompact` on, diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 88b980e32..183fa8610 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -4,6 +4,11 @@ DeepCode 的主要变化,最新的在前,每天一条。正式版本号写 PyPI 上的包名是 [deepcode-hku](https://pypi.org/project/deepcode-hku/)。 English: [CHANGELOG.md](CHANGELOG.md). +## 2026-10-31 · 整理记忆索引时保留不同主题 + +- **不再误合并不同主题链接。** 文件名前缀相同、标题或目标中包含 `|` 时, + 不同的记忆指针仍会保留;重复指针和正文去重规则保持不变。 + ## 2026-10-29 · 更轻的上下文压缩、目标完成前先检查、Web 端可浏览文件夹 - **先清掉旧的工具输出。** 打开 `agents.defaults.microcompact` 后,长会话接近 diff --git a/core/harness/memory.py b/core/harness/memory.py index a09ca0dde..6947ec6f2 100644 --- a/core/harness/memory.py +++ b/core/harness/memory.py @@ -764,27 +764,31 @@ def _line_key(line: str) -> str: return text.rstrip(" .,;:!—-") -def _candidate_key(line: str) -> str: +_CandidateKey = str | tuple[str, str] + + +def _candidate_key(line: str) -> _CandidateKey: """Dedupe key: pointer identity for pointers, normalized text otherwise.""" pointer = parse_memory_pointer(line) if pointer is not None: # Same title + target with a different hook is one topic, not two. - return f"{pointer.title.casefold()}|{pointer.target.casefold()}" + return (pointer.title.casefold(), pointer.target.casefold()) return _line_key(line) -def _drop_subsumed(order: Sequence[str]) -> list[str]: +def _drop_subsumed(order: Sequence[_CandidateKey]) -> list[_CandidateKey]: """Keys whose normalized form is a strict prefix of a longer entry. Safe by construction: a strict prefix is literally contained in the longer line, so folding the stub into it cannot lose content. """ - dropped: set[str] = set() + dropped: set[_CandidateKey] = set() for key in order: - if len(key) < _MIN_USEFUL_CHARS: + # Prefix subsumption applies to prose, never to distinct topic links. + if not isinstance(key, str) or len(key) < _MIN_USEFUL_CHARS: continue for other in order: - if other == key or other in dropped: + if not isinstance(other, str) or other == key or other in dropped: continue if len(other) > len(key) and other.startswith(key): dropped.add(key) @@ -801,8 +805,8 @@ def consolidate(lines: Sequence[str]) -> list[str]: order is first-seen, and first-seen is decided solely by the input sequence: no set or dict iteration decides anything. """ - unique: dict[str, str] = {} - order: list[str] = [] + unique: dict[_CandidateKey, str] = {} + order: list[_CandidateKey] = [] for raw in lines: text = " ".join(str(raw).split()) key = _candidate_key(text) diff --git a/tests/test_memory_layers.py b/tests/test_memory_layers.py index 80588f443..67cff50bf 100644 --- a/tests/test_memory_layers.py +++ b/tests/test_memory_layers.py @@ -30,6 +30,7 @@ MemoryPointer, consolidate, consolidate_memory_index, + consolidate_pointer_index, fetch_memory_topic, is_pointer_index, memory_dir, @@ -256,6 +257,45 @@ def test_consolidate_dedupes_and_merges_prefixes(): ] +@pytest.mark.parametrize( + "lines", + [ + [ + "- [API reference](api.md) — current endpoint contract", + "- [API reference](api.md.v2.md) — next version contract", + ], + [ + "- [A|B](c.md) — first topic", + "- [A](B|c.md) — separate topic", + ], + ], +) +@pytest.mark.parametrize("reverse", [False, True]) +def test_consolidate_keeps_distinct_pointer_identities(lines, reverse): + if reverse: + lines = list(reversed(lines)) + assert consolidate(lines) == lines + + +def test_pointer_index_rewrite_keeps_distinct_topics(tmp_path): + first = "- [API reference](api.md) — current endpoint contract" + second = "- [API reference](api.md.v2.md) — next version contract" + index = _write(tmp_path, "MEMORY.md", f"{first}\n{second}\n{first}\n") + _write(tmp_path, "api.md", "Current endpoint: /v1/search\n") + _write(tmp_path, "api.md.v2.md", "Next endpoint: /v2/search\n") + + assert consolidate_pointer_index(tmp_path) + + assert index.read_text(encoding="utf-8") == f"{first}\n{second}\n" + assert ( + fetch_memory_topic(tmp_path, "api.md").text == "Current endpoint: /v1/search\n" + ) + assert ( + fetch_memory_topic(tmp_path, "api.md.v2.md").text + == "Next endpoint: /v2/search\n" + ) + + def test_prune_line_cap_at_the_boundary(): lines = ["first line", "second line", "third line"] assert prune(lines, max_lines=3) == lines # exactly at the cap